/**
 * YYShop Admin — single-page React app.
 * Pages: Dashboard · Products · Categories · Orders · Channels · Themes · Banners · Culture · i18n · Users
 *
 * Full Chinese/English UI with zh as the default. Language preference is
 * persisted to localStorage (key "yy_admin_lang") and toggleable from the
 * top-bar. Products support multi-channel visibility + subcategory; there is
 * a dedicated Users page for managing registered accounts and email
 * verification (via Resend, backed by /api/admin/users endpoints).
 */

const { useState, useEffect, useMemo, useRef, useCallback } = React;

const LANG_ORDER = ['en','zh','id','th','vi','es','fr','ar'];
const IMG_STYLES = ['silk','clay','porcelain','jade','brush','gift','coat','linen','shirt','lip','serum','powder','perfume'];

// ============================ i18n dictionary ==============================
const DICT = {
  zh: {
    'app.admin': '后台',
    'app.storefront_link': '前台 ↗',
    'app.lang_toggle': 'EN',

    'group.overview':   '概览',
    'group.catalog':    '商品',
    'group.sales':      '销售',
    'group.storefront': '店铺',
    'group.content':    '内容',
    'group.users':      '用户',
    'group.system':     '系统',

    'nav.dashboard':  '仪表盘',
    'nav.products':   '商品',
    'nav.categories': '分类',
    'nav.tags':       '标签',
    'nav.sections':   '网站频道',
    'nav.orders':     '订单',
    'nav.channels':   '国别频道',
    'nav.themes':     '主题',
    'nav.banners':    '首页横幅',
    'nav.culture':    '文化专区',
    'nav.i18n':       '多语言文案',
    'nav.users':      '注册用户',
    'nav.settings':   '系统设置',
    'nav.deploy':     '部署',

    'common.save':     '保存',
    'common.cancel':   '取消',
    'common.edit':     '编辑',
    'common.delete':   '删除',
    'common.hide':     '隐藏',
    'common.show':     '显示',
    'common.view':     '查看',
    'common.ship':     '发货',
    'common.cancel_order': '作废',
    'common.loading':  '加载中…',
    'common.empty':    '暂无数据',
    'common.filter':   '筛选',
    'common.search':   '搜索',
    'common.new':      '新建',
    'common.active':   '上架',
    'common.disabled': '停用',
    'common.live':     '启用',
    'common.off':      '下架',
    'common.id':       'ID',
    'common.code':     '代码',
    'common.name':     '名称',
    'common.sort_order': '排序',
    'common.color':    '颜色',
    'common.state':    '状态',
    'common.all':      '全部',
    'common.items':    '条',
    'common.keys':     '个',
    'common.yes':      '是',
    'common.no':       '否',
    'common.none':     '—',
    'common.saved':    '已保存',
    'common.deleted':  '已删除',

    'dash.title':         '概览',
    'dash.sub':           '今日 — 欢迎回来',
    'dash.kpi_products':  '商品',
    'dash.kpi_orders':    '订单',
    'dash.kpi_revenue':   '营收',
    'dash.kpi_channels':  '频道',
    'dash.active_skus':   '在架 SKU',
    'dash.alltime':       '累计',
    'dash.lifetime':      '累计,美元',
    'dash.channels_live': '已上线',
    'dash.recent_orders': '最近订单',
    'dash.view_all':      '查看全部 →',
    'dash.no_orders':     '暂无订单,可到前台下单试试。',
    'dash.col_order':     '订单号',
    'dash.col_email':     '邮箱',
    'dash.col_channel':   '频道',
    'dash.col_status':    '状态',
    'dash.col_total':     '金额',
    'dash.col_placed':    '下单时间',

    'products.title':       '商品管理',
    'products.count':       '个商品',
    'products.new':         '+ 新商品',
    'products.col_name':    '名称 (中 / 英)',
    'products.col_category':'分类',
    'products.col_price':   '价格',
    'products.col_style':   '样式',
    'products.col_channels':'频道',
    'products.featured':    '精选',
    'products.filter_cat':  '全部分类',
    'products.filter_ch':   '全部频道',
    'products.field_id':        '商品 ID',
    'products.field_category':  '分类',
    'products.field_subcat':    '子分类 (可留空)',
    'products.field_channels':  '频道可见性',
    'products.field_channels_help': '不选 = 所有频道可见;勾选后仅在所选频道展示',
    'products.field_price':     '价格 (USD)',
    'products.field_was':       '划线价 (可选)',
    'products.field_color':     '主色 (hex)',
    'products.field_style':     '图片样式',
    'products.field_tag':       '徽章标签',
    'products.field_stock':     '库存',
    'products.field_sort':      '排序',
    'products.field_flags':     '状态',
    'products.field_active':    '上架',
    'products.field_featured':  '精选',
    'products.field_name':      '商品名 · 多语言',
    'products.field_desc':      '简介 · 多语言',
    'products.field_material':  '材质 · 多语言',
    'products.field_fit':       '版型 / 尺码建议 · 多语言',
    'products.field_care':      '保养 · 多语言',
    'products.id_required':     'ID 为必填',
    'products.delete_confirm':  '确认删除商品 {id} ?',
    'products.visible_all':     '全部频道',
    'products.view_pdp':        '查看',
    'products.col_thumb':       '图',
    'products.show_thumbs':     '显示缩略图',
    'products.hide_thumbs':     '隐藏缩略图',
    'products.detail_section':  '详情介绍 (前台 PDP 标签页内容)',
    'products.basic_section':   '基本信息',
    'products.media_section':   '商品图片 (主图 + 任意张画廊图)',
    'products.media_hint':      '主图用于列表卡片和社交分享;画廊可以添加任意张,在前台 PDP 切换查看。',
    'products.slot_primary':    '主图',
    'products.slot_n':          '画廊 #{n}',
    'products.upload':          '上传',
    'products.ai_gen':          'AI 生成',
    'products.clear':           '清空',
    'products.paste_url':       '或粘贴 URL',
    'products.uploading':       '上传中…',
    'products.generating':      'AI 生成中…',
    'products.ai_gen_ok':       'AI 生成完成',
    'products.upload_ok':       '上传成功',
    'products.cleared':         '已清空',
    'products.no_image':        '暂无图片',
    'products.must_save_first': '请先保存商品再上传图片',
    'products.add_slot':        '+ 添加画廊图',
    'products.move_up':         '上移',
    'products.move_down':       '下移',
    'products.remove':          '删除',
    'products.prompt_title':    'AI 生图提示词',
    'products.prompt_hint':     '可以直接修改以下提示词,再点击"开始生成"。',
    'products.prompt_confirm':  '开始生成',
    'products.colors_section':  '颜色变体 (可选:每色独立图片)',
    'products.colors_hint':     '每个颜色可以额外配置主图和画廊;前台 PDP 切换颜色时会自动显示对应的图片。',
    'products.references_section': '实物图片 (商家上传,作为 AI 生图参考)',
    'products.references_hint': '上传 1-8 张真实拍摄图。AI 生成颜色图片时会以这些图为视觉参考。',
    'products.references_empty': '暂无实物图,点击下方上传',
    'products.references_add':  '+ 上传实物图',
    'products.batch_ai':        'AI 一键生成所有颜色',
    'products.batch_ai_hint':   '基于一张参考图,为勾选的颜色批量生成主图 + 画廊。',
    'products.batch_select_colors': '选择颜色',
    'products.batch_select_all':'全选',
    'products.batch_images_per': '每色生成图数',
    'products.batch_run':       '开始生成',
    'products.batch_progress':  '进度',
    'products.batch_done':      '完成',
    'products.batch_failed':    '失败',
    'products.batch_no_colors': '商品还没有配置任何颜色,请先在上方添加颜色变体。',
    'products.batch_save_first':'请先保存商品,然后再触发批量生成。',
    'products.batch_no_ref':    '请至少上传一张实物图后再使用 AI 一键生成。',
    'products.batch_ref_label': '选择参考图',
    'products.batch_ref_real':  '实物图',
    'products.batch_ref_color': '颜色已有图',
    'products.batch_prompt':    '提示词模板 (可编辑)',
    'products.batch_prompt_hint': '{color} 会自动替换为每个颜色的名字,{colorHex} 替换为色值。',
    'products.batch_model':     '包含真人模特',
    'products.batch_model_hint':'勾选后,每色的最后 N 张会安排成"上身 / 手持 / 行走"等模特图(避免露脸)。',
    'products.batch_model_count':'模特照张数',
    'products.batch_per_shot':  '每张图的差异化指令 (可编辑)',
    'products.batch_per_shot_hint':'每张图都会单独 append 一段 directive,这是它们之间长得不一样的关键。下面是默认池子里挑出来的;你可以为某张单独改写。',
    'products.batch_shot':      '第',
    'products.batch_shot_unit': '张',
    'products.batch_shot_product':'(产品)',
    'products.batch_shot_model': '(模特)',
    'products.batch_full_prompts': '每张图的提示词 (中文,可编辑)',
    'products.batch_full_prompts_hint': '点上面"生成图片提示词"由 AI 一次性写好 N 段;{color} 会自动替换为颜色名,{colorHex} 替换为色值。',
    'products.batch_gen_prompts':'✨ 生成图片提示词',
    'products.batch_prompts_loading':'AI 写词中...',
    'products.batch_prompts_required':'请先生成或填写每张图的提示词,再开始生成图片。',
    'products.colors_empty':    '暂无颜色变体 (商品将只使用上面的"主色")',
    'products.color_name':      '颜色名称 · 多语言',
    'products.add_color':       '添加颜色变体',
    'products.field_tags':      '标签',
    'products.field_tags_help': '勾选商品隶属的标签 (可用于站点频道 / 筛选)',
    'products.no_tags':         '尚未创建任何标签',

    'settings.title':           '系统设置',
    'settings.sub':              '邮件服务器 · AI 生成 API · 运行时配置',
    'settings.save':             '保存设置',
    'settings.saved':            '已保存',
    'settings.sec_ai':           'AI 生成 (OpenAI 兼容网关)',
    'settings.ai_base':          'Base URL',
    'settings.ai_key':           'API Key',
    'settings.ai_text_model':    '文本模型',
    'settings.ai_image_model':   '图像模型',
    'settings.sec_mail':         '邮件服务',
    'settings.mail_provider':    '发送方式',
    'settings.mail_provider_none':  '不发送',
    'settings.mail_provider_resend':'Resend (HTTP API)',
    'settings.mail_provider_smtp':  'SMTP 服务器',
    'settings.mail_from':        '发件人 (From)',
    'settings.resend_key':       'Resend API Key',
    'settings.smtp_host':        'SMTP Host',
    'settings.smtp_port':        'Port',
    'settings.smtp_user':        '用户名',
    'settings.smtp_pass':        '密码',
    'settings.smtp_secure':      'SSL (端口 465 自动勾选)',
    'settings.smtp_hint':        'SMTP 需要服务器已安装 nodemailer (npm i nodemailer)。',
    'settings.test_mail':        '发送测试邮件',
    'settings.sec_stripe':       'Stripe 支付',
    'settings.stripe_mode':      '当前模式',
    'settings.stripe_mode_live': 'Live (真实交易)',
    'settings.stripe_mode_test': 'Test (测试卡 4242…,不扣钱)',
    'settings.stripe_pk':        'Publishable Key · Live (前端,pk_live_…)',
    'settings.stripe_sk':        'Secret / Restricted Key · Live (服务端,sk_live_… / rk_live_…)',
    'settings.stripe_pk_test':   'Publishable Key · Test (pk_test_…)',
    'settings.stripe_sk_test':   'Secret / Restricted Key · Test (sk_test_… / rk_test_…)',
    'settings.stripe_whsec':     'Webhook Signing Secret (可选)',
    'settings.stripe_currency':  '币种 (默认 usd)',
    'settings.stripe_hint':      '4 把 key 都明文保存到 yyshop.settings.stripe。模式切到 Test 时用 test key 跑测试卡(4242 4242 4242 4242);切到 Live 走真钱。两套互不影响。',
    'settings.test_to':          '测试收件地址',
    'settings.test_ok':          '测试邮件已发送',
    'settings.empty_placeholder':'(当前使用环境变量或默认值)',

    'orders.title':         '订单管理',
    'orders.count':         '个订单',
    'orders.col_order':     '订单号',
    'orders.col_customer':  '客户',
    'orders.col_channel':   '频道',
    'orders.col_items':     '件数',
    'orders.col_total':     '总额',
    'orders.col_placed':    '下单时间',
    'orders.all_statuses':  '全部状态',
    'orders.delete_confirm':'确认删除订单 {id} ?',
    'orders.detail_title':  '订单 {no}',
    'orders.f_customer':    '客户',
    'orders.f_email':       '邮箱',
    'orders.f_phone':       '电话',
    'orders.f_payment':     '支付方式',
    'orders.f_address':     '收件地址',
    'orders.items_h':       '商品明细',
    'orders.subtotal':      '小计',
    'orders.shipping':      '运费',
    'orders.tax':           '税费',
    'orders.total':         '总计',
    'orders.col_ideprod':   '商品ID',

    'status.pending':   '待处理',
    'status.paid':      '已支付',
    'status.shipped':   '已发货',
    'status.delivered': '已送达',
    'status.cancelled': '已作废',

    'channels.title':     '频道管理',
    'channels.sub':       '个频道 · 每个频道默认绑定一个主题',
    'channels.new':       '+ 新建频道',
    'channels.col_theme': '主题',
    'channels.col_flag':  '旗帜',
    'channels.field_id':    '频道 ID',
    'channels.field_theme': '默认主题',
    'channels.field_flag':  '旗帜 / emoji / 2 字母',
    'channels.field_sort':  '排序',
    'channels.field_name':  '名称 · 多语言',
    'channels.field_desc':  '介绍 · 多语言',
    'channels.delete_confirm': '确认删除频道 {id} ?',

    'themes.title':  '主题皮肤',
    'themes.sub':    '个皮肤 — 编辑名称、标签、色板与强调色。主题 CSS 打包在店铺前端。',
    'themes.field_label_zh': '中文副标签',
    'themes.field_accent':   '强调色',
    'themes.field_swatch':   '色板 (逗号分隔 hex)',
    'themes.field_sort':     '排序',
    'themes.field_name':     '名称 · 多语言',
    'themes.accent':         '强调色',

    'banners.title': '首页横幅',
    'banners.sub':   '按频道区分的 Hero 内容 · 展示于前台首页',
    'banners.new':   '+ 新横幅',
    'banners.col_title_en':  '标题 (英)',
    'banners.col_cta_target':'目标',
    'banners.field_channel':   '频道',
    'banners.field_cta_target':'CTA 跳转',
    'banners.field_sort':      '排序',
    'banners.field_active':    '启用',
    'banners.field_tag':       '小标签 · 多语言',
    'banners.field_title':     '主标题 · 多语言 (使用 \\n 换行)',
    'banners.field_subtitle':  '副标题 · 多语言',
    'banners.field_cta_label': 'CTA 文案 · 多语言',
    'banners.delete_confirm':  '确认删除横幅 #{id} ?',

    'culture.title': '文化专区',
    'culture.sub':   '展示于文化频道页的卡片',
    'culture.new':   '+ 新专题',
    'culture.col_mark': '标记',
    'culture.col_title_en': '标题 (英)',
    'culture.col_desc_en':  '描述 (英)',
    'culture.field_mark':  '标记 (1 - 2 个字)',
    'culture.field_color': '卡片底色',
    'culture.field_sort':  '排序',
    'culture.field_active':'启用',
    'culture.field_title': '标题 · 多语言',
    'culture.field_desc':  '描述 · 多语言',
    'culture.delete_confirm': '确认删除该专题?',

    'i18nkeys.title':   '多语言文案',
    'i18nkeys.count':   '条 · {n} 种语言',
    'i18nkeys.new':     '+ 新词条',
    'i18nkeys.filter':  '按 key 搜索…',
    'i18nkeys.col_key': 'Key',
    'i18nkeys.others':  '其他 {n} / {total} 种已翻译',
    'i18nkeys.field_key': 'Key (建议分命名空间,如 hero.title)',
    'i18nkeys.field_values': '各语言文案',
    'i18nkeys.delete_confirm': '确认删除 "{key}" ?',
    'i18nkeys.key_required': 'Key 为必填',

    'categories.title':    '分类管理',
    'categories.new':      '+ 新分类',
    'categories.new_top':  '+ 新一级分类',
    'categories.new_child':'+ 子分类',
    'categories.count':    '个分类',
    'categories.field_code': '分类代码',
    'categories.field_parent': '父级分类',
    'categories.field_parent_none': '(顶级 · 无父级)',
    'categories.field_sort': '排序',
    'categories.field_active':'启用',
    'categories.field_name': '名称 · 多语言',
    'categories.delete_confirm': '确认删除分类 {code} ?',
    'categories.code_required':  '代码为必填',
    'categories.cycle':          '不能将子分类设为自己的父级,会形成循环',
    'categories.add_child':      '添加子分类',
    'categories.expand_all':     '全部展开',
    'categories.collapse_all':   '全部收起',
    'categories.tree_empty':     '暂无分类,点击"新一级分类"开始添加',
    'categories.has_children':   '{n} 个子分类',

    'tags.title':          '标签管理',
    'tags.sub':            '标签与分类正交。可用于礼品、限量、手工、新品等虚拟筛选。',
    'tags.new':            '+ 新标签',
    'tags.count':          '个标签',
    'tags.field_code':     '标签代码',
    'tags.field_name':     '名称 · 多语言',
    'tags.field_sort':     '排序',
    'tags.field_active':   '启用',
    'tags.delete_confirm': '确认删除标签 {code} ?',
    'tags.code_required':  '代码为必填',
    'products.field_tags':      '产品标签 (多选)',
    'products.field_tags_help': '可叠加多个标签,用于礼品、限量、新品等虚拟频道。',
    'products.no_tags':         '暂无标签',

    'sections.title':       '网站频道',
    'sections.sub':         '顶部导航。可选分类 / 子分类 / 标签,并为每个频道挑选布局、推荐商品与默认皮肤。',
    'sections.new':         '+ 新频道',
    'sections.count':       '个频道',
    'sections.field_id':    '频道 ID',
    'sections.field_name':  '名称 · 多语言',
    'sections.field_sort':  '排序',
    'sections.field_active':'启用',
    'sections.field_layout':'布局模版',
    'sections.layout_home': '首页风格 (推荐区 + 分类展示)',
    'sections.layout_culture': '中国特色风格',
    'sections.field_skin':  '默认皮肤',
    'sections.field_skin_none':'(不强制,跟随全局)',
    'sections.field_accent':'强调色',
    'sections.field_include':'包含规则',
    'sections.include_help':'勾选后,该频道将展示匹配任意条件的商品(分类 或 标签)。',
    'sections.include_cats':'包含分类',
    'sections.include_desc': '含子分类',
    'sections.include_tags':'包含标签',
    'sections.include_virtual':'虚拟规则',
    'sections.virtual_sale':'特价 (was_price 非空)',
    'sections.virtual_featured':'精选 (featured=true)',
    'sections.virtual_new': '上新 (最近 {n} 天)',
    'sections.field_hero':  '推荐区域',
    'sections.hero_title':  '标题 · 多语言',
    'sections.hero_subtitle':'副标题 · 多语言',
    'sections.hero_cta':    'CTA 文案 · 多语言',
    'sections.hero_cta_target':'CTA 链接 / 目标',
    'sections.hero_product_ids':'推荐商品 ID (逗号分隔,留空则自动取前 4 个)',
    'sections.hero_picks':      '推荐位 (卡片 + 文案覆盖)',
    'sections.hero_picks_help': '每条推荐都对应一个商品;可单独设置封面图、标题、副标题、按钮文案。封面图会出现在首页轮播和频道顶部卡片区。',
    'sections.hero_picks_empty':'暂无推荐位 · 留空则前台自动取前 4 个商品',
    'sections.hero_picks_add':  '添加推荐位',
    'sections.pick_cover':      '封面图',
    'sections.pick_title':      '标题覆盖 · 多语言',
    'sections.pick_subtitle':   '副标题覆盖 · 多语言',
    'sections.pick_cta':        'CTA 文案覆盖 · 多语言',
    'sections.pick_cta_target_ph':'跳转目标 (留空则默认打开商品详情)',
    'sections.delete_confirm':'确认删除频道 {id} ?',
    'sections.id_required': 'ID 为必填',
    'sections.preview':     '前台预览',

    'users.title':       '注册用户',
    'users.count':       '位用户',
    'users.kpi_total':   '总用户',
    'users.kpi_verified':'已验证',
    'users.kpi_disabled':'被停用',
    'users.kpi_week':    '本周新增',
    'users.search':      '按邮箱或姓名搜索…',
    'users.filter_all':        '全部',
    'users.filter_verified':   '已验证',
    'users.filter_unverified': '未验证',
    'users.filter_disabled':   '已停用',
    'users.col_email':   '邮箱',
    'users.col_name':    '姓名',
    'users.col_locale':  '语言',
    'users.col_verified':'邮箱验证',
    'users.col_created': '注册时间',
    'users.col_admin':   '管理员',
    'users.no_users':    '暂无注册用户',
    'users.edit':        '编辑',
    'users.resend':      '重发验证',
    'users.disable':     '停用',
    'users.enable':      '启用',
    'users.delete_confirm': '确认删除用户 {email} ?此操作不可恢复。',
    'users.edit_title':  '编辑用户 · {email}',
    'users.field_name':  '姓名',
    'users.field_phone': '电话',
    'users.field_locale':'默认语言',
    'users.field_verified':'邮箱已验证',
    'users.field_active':'账号可登录',
    'users.field_admin': '管理员权限',
    'users.recent_orders': '最近订单',
    'users.no_recent_orders': '暂无订单',
    'users.resent_ok':   '已发送验证邮件',
    'users.resent_already':'用户已验证,无需发送',
    'users.verified_pill':  '已验证',
    'users.unverified_pill':'未验证',

    'mail.not_configured': '未配置 RESEND_API_KEY,邮件未发送',
    'mail.sent':           '已发送',
  },

  en: {
    'app.admin': 'Admin',
    'app.storefront_link': 'Storefront ↗',
    'app.lang_toggle': '中',

    'group.overview':   'Overview',
    'group.catalog':    'Catalog',
    'group.sales':      'Sales',
    'group.storefront': 'Storefront',
    'group.content':    'Content',
    'group.users':      'Users',
    'group.system':     'System',

    'nav.dashboard':  'Dashboard',
    'nav.products':   'Products',
    'nav.categories': 'Categories',
    'nav.tags':       'Tags',
    'nav.sections':   'Site Sections',
    'nav.orders':     'Orders',
    'nav.channels':   'Regional Channels',
    'nav.themes':     'Themes',
    'nav.banners':    'Home Banners',
    'nav.culture':    'Culture Zone',
    'nav.i18n':       'i18n Strings',
    'nav.users':      'Registered Users',
    'nav.settings':   'Settings',
    'nav.deploy':     'Deploy',

    'common.save':     'Save',
    'common.cancel':   'Cancel',
    'common.edit':     'Edit',
    'common.delete':   'Delete',
    'common.hide':     'Hide',
    'common.show':     'Show',
    'common.view':     'View',
    'common.ship':     'Ship',
    'common.cancel_order': 'Cancel',
    'common.loading':  'Loading…',
    'common.empty':    'Empty',
    'common.filter':   'Filter',
    'common.search':   'Search',
    'common.new':      'New',
    'common.active':   'active',
    'common.disabled': 'disabled',
    'common.live':     'Live',
    'common.off':      'off',
    'common.id':       'ID',
    'common.code':     'Code',
    'common.name':     'Name',
    'common.sort_order': 'Order',
    'common.color':    'Color',
    'common.state':    'State',
    'common.all':      'All',
    'common.items':    'items',
    'common.keys':     'keys',
    'common.yes':      'Yes',
    'common.no':       'No',
    'common.none':     '—',
    'common.saved':    'Saved',
    'common.deleted':  'Deleted',

    'dash.title':         'Overview',
    'dash.sub':           'Today — welcome back',
    'dash.kpi_products':  'Products',
    'dash.kpi_orders':    'Orders',
    'dash.kpi_revenue':   'Revenue',
    'dash.kpi_channels':  'Channels',
    'dash.active_skus':   'active SKUs',
    'dash.alltime':       'all time',
    'dash.lifetime':      'USD, lifetime',
    'dash.channels_live': 'live',
    'dash.recent_orders': 'Recent orders',
    'dash.view_all':      'View all →',
    'dash.no_orders':     'No orders yet. Place one via the storefront to see it here.',
    'dash.col_order':     'Order',
    'dash.col_email':     'Email',
    'dash.col_channel':   'Channel',
    'dash.col_status':    'Status',
    'dash.col_total':     'Total',
    'dash.col_placed':    'Placed',

    'products.title':       'Products',
    'products.count':       'items',
    'products.new':         '+ New product',
    'products.col_name':    'Name (EN / ZH)',
    'products.col_category':'Category',
    'products.col_price':   'Price',
    'products.col_style':   'Style',
    'products.col_channels':'Channels',
    'products.featured':    'featured',
    'products.filter_cat':  'All categories',
    'products.filter_ch':   'All channels',
    'products.field_id':        'Product ID',
    'products.field_category':  'Category',
    'products.field_subcat':    'Subcategory (optional)',
    'products.field_channels':  'Channel visibility',
    'products.field_channels_help': 'None checked = visible in ALL channels; checked = only those channels.',
    'products.field_price':     'Price (USD)',
    'products.field_was':       'Was price (strike-through)',
    'products.field_color':     'Color (hex)',
    'products.field_style':     'Image style',
    'products.field_tag':       'Tag (optional badge)',
    'products.field_stock':     'Stock',
    'products.field_sort':      'Sort order',
    'products.field_flags':     'Flags',
    'products.field_active':    'Active',
    'products.field_featured':  'Featured',
    'products.field_name':      'Name · multi-language',
    'products.field_desc':      'Short description · multi-language',
    'products.field_material':  'Material · multi-language',
    'products.field_fit':       'Fit / sizing · multi-language',
    'products.field_care':      'Care · multi-language',
    'products.id_required':     'ID is required',
    'products.delete_confirm':  'Delete product {id}?',
    'products.visible_all':     'All channels',
    'products.view_pdp':        'View',
    'products.col_thumb':       'Thumb',
    'products.show_thumbs':     'Show thumbnails',
    'products.hide_thumbs':     'Hide thumbnails',
    'products.detail_section':  'Detail copy (storefront PDP tabs)',
    'products.basic_section':   'Basic info',
    'products.media_section':   'Images (primary + unlimited gallery shots)',
    'products.media_hint':      'The primary image appears on list cards and social shares; add as many gallery shots as you like.',
    'products.slot_primary':    'Primary',
    'products.slot_n':          'Gallery #{n}',
    'products.upload':          'Upload',
    'products.ai_gen':          'AI generate',
    'products.clear':           'Clear',
    'products.paste_url':       'or paste URL',
    'products.uploading':       'Uploading…',
    'products.generating':      'Generating…',
    'products.ai_gen_ok':       'AI image ready',
    'products.upload_ok':       'Upload complete',
    'products.cleared':         'Cleared',
    'products.no_image':        'No image',
    'products.must_save_first': 'Save the product first before uploading images',
    'products.add_slot':        '+ Add gallery image',
    'products.move_up':         'Move up',
    'products.move_down':       'Move down',
    'products.remove':          'Remove',
    'products.prompt_title':    'AI image prompt',
    'products.prompt_hint':     'Edit the prompt below, then click "Generate".',
    'products.prompt_confirm':  'Generate',
    'products.colors_section':  'Color variants (optional — per-color media)',
    'products.colors_hint':     'Each color can override its own primary + gallery. The storefront PDP swaps images automatically when users pick a swatch.',
    'products.references_section': 'Reference photos (merchant-uploaded, used as AI inspiration)',
    'products.references_hint': 'Upload 1-8 real product photographs. The AI image generator uses these as a visual reference.',
    'products.references_empty':'No reference photos yet — upload below.',
    'products.references_add':  '+ Upload reference photo',
    'products.batch_ai':        'AI: generate every color',
    'products.batch_ai_hint':   'Use the reference photos to generate primary + gallery images for the colors you pick.',
    'products.batch_select_colors':'Pick colors',
    'products.batch_select_all':'Select all',
    'products.batch_images_per':'Images per color',
    'products.batch_run':       'Start',
    'products.batch_progress':  'Progress',
    'products.batch_done':      'done',
    'products.batch_failed':    'failed',
    'products.batch_no_colors': 'No color variants yet — add them above first.',
    'products.batch_save_first':'Save the product first, then trigger batch generation.',
    'products.batch_no_ref':    'Upload at least one reference photo before using AI batch generation.',
    'products.batch_ref_label': 'Reference image',
    'products.batch_ref_real':  'Reference photos',
    'products.batch_ref_color': 'Existing color images',
    'products.batch_prompt':    'Prompt template (editable)',
    'products.batch_prompt_hint':'{color} is replaced per variant; {colorHex} is the hex value.',
    'products.batch_model':     'Include real-life model',
    'products.batch_model_hint':'When on, the LAST N shots per color use a model (cropped framing, no faces).',
    'products.batch_model_count':'Model shots per color',
    'products.batch_per_shot':  'Per-shot directive (editable)',
    'products.batch_per_shot_hint':'Each shot has its own directive appended after the common template — that\'s what makes them look different. Tweak any row to override.',
    'products.batch_shot':      'Shot',
    'products.batch_shot_unit': '',
    'products.batch_shot_product':'(product)',
    'products.batch_shot_model': '(model)',
    'products.batch_full_prompts': 'Per-shot prompts (Chinese, editable)',
    'products.batch_full_prompts_hint':'Click "Generate Prompts" above to have AI fill them in one shot. {color} / {colorHex} are replaced per variant.',
    'products.batch_gen_prompts':'✨ Generate Prompts',
    'products.batch_prompts_loading':'AI writing...',
    'products.batch_prompts_required':'Generate or fill the per-shot prompts before starting image generation.',
    'products.colors_empty':    'No color variants yet (the storefront will just use the default "Color" above).',
    'products.color_name':      'Color name · multi-language',
    'products.add_color':       'Add color variant',
    'products.field_tags':      'Tags',
    'products.field_tags_help': 'Tick tags this product belongs to (used by site sections and filters).',
    'products.no_tags':         'No tags created yet.',

    'settings.title':            'System Settings',
    'settings.sub':              'Mail server · AI gateway · runtime configuration',
    'settings.save':             'Save settings',
    'settings.saved':            'Saved',
    'settings.sec_ai':           'AI image / chat gateway (OpenAI-compatible)',
    'settings.ai_base':          'Base URL',
    'settings.ai_key':           'API Key',
    'settings.ai_text_model':    'Text model',
    'settings.ai_image_model':   'Image model',
    'settings.sec_mail':         'Mail delivery',
    'settings.mail_provider':    'Provider',
    'settings.mail_provider_none':  'Do not send',
    'settings.mail_provider_resend':'Resend (HTTP API)',
    'settings.mail_provider_smtp':  'SMTP server',
    'settings.mail_from':        'From address',
    'settings.resend_key':       'Resend API key',
    'settings.smtp_host':        'SMTP host',
    'settings.smtp_port':        'Port',
    'settings.smtp_user':        'Username',
    'settings.smtp_pass':        'Password',
    'settings.smtp_secure':      'Use SSL (port 465 auto-enables)',
    'settings.smtp_hint':        'SMTP requires nodemailer on the server (npm i nodemailer).',
    'settings.test_mail':        'Send test email',
    'settings.sec_stripe':       'Stripe Payments',
    'settings.stripe_mode':      'Current mode',
    'settings.stripe_mode_live': 'Live (real charges)',
    'settings.stripe_mode_test': 'Test (test cards like 4242…, no money moved)',
    'settings.stripe_pk':        'Publishable key · Live (pk_live_…)',
    'settings.stripe_sk':        'Secret / Restricted key · Live (sk_live_… / rk_live_…)',
    'settings.stripe_pk_test':   'Publishable key · Test (pk_test_…)',
    'settings.stripe_sk_test':   'Secret / Restricted key · Test (sk_test_… / rk_test_…)',
    'settings.stripe_whsec':     'Webhook signing secret (optional)',
    'settings.stripe_currency':  'Currency (default usd)',
    'settings.stripe_hint':      'All four keys stored plaintext under yyshop.settings.stripe. Switching mode just selects which pair the server / storefront use.',
    'settings.test_to':          'Send test to',
    'settings.test_ok':          'Test email sent',
    'settings.empty_placeholder':'(using env / default)',

    'orders.title':         'Orders',
    'orders.count':         'orders',
    'orders.col_order':     'Order',
    'orders.col_customer':  'Customer',
    'orders.col_channel':   'Channel',
    'orders.col_items':     'Items',
    'orders.col_total':     'Total',
    'orders.col_placed':    'Placed',
    'orders.all_statuses':  'All statuses',
    'orders.delete_confirm':'Delete order {id}?',
    'orders.detail_title':  'Order {no}',
    'orders.f_customer':    'Customer',
    'orders.f_email':       'Email',
    'orders.f_phone':       'Phone',
    'orders.f_payment':     'Payment',
    'orders.f_address':     'Address',
    'orders.items_h':       'Items',
    'orders.subtotal':      'Subtotal',
    'orders.shipping':      'Shipping',
    'orders.tax':           'Tax',
    'orders.total':         'Total',
    'orders.col_ideprod':   'ID',

    'status.pending':   'pending',
    'status.paid':      'paid',
    'status.shipped':   'shipped',
    'status.delivered': 'delivered',
    'status.cancelled': 'cancelled',

    'channels.title':     'Channels',
    'channels.sub':       'channels · each carries a default theme',
    'channels.new':       '+ New channel',
    'channels.col_theme': 'Theme',
    'channels.col_flag':  'Flag',
    'channels.field_id':    'Channel ID',
    'channels.field_theme': 'Default theme',
    'channels.field_flag':  'Flag / emoji / 2-letter',
    'channels.field_sort':  'Sort order',
    'channels.field_name':  'Name · multi-language',
    'channels.field_desc':  'Description · multi-language',
    'channels.delete_confirm': 'Delete channel {id}?',

    'themes.title':  'Themes',
    'themes.sub':    'skins — edit name, label, swatch and accent. (Theme CSS is shipped with the storefront.)',
    'themes.field_label_zh': 'Label (ZH)',
    'themes.field_accent':   'Accent color',
    'themes.field_swatch':   'Swatch (comma-separated hex)',
    'themes.field_sort':     'Sort order',
    'themes.field_name':     'Name · multi-language',
    'themes.accent':         'accent',

    'banners.title': 'Home Banners',
    'banners.sub':   'Hero content by channel · shows on storefront home',
    'banners.new':   '+ New banner',
    'banners.col_title_en':  'Title (EN)',
    'banners.col_cta_target':'CTA target',
    'banners.field_channel':   'Channel',
    'banners.field_cta_target':'CTA target screen',
    'banners.field_sort':      'Sort order',
    'banners.field_active':    'Active',
    'banners.field_tag':       'Tag · multi-language',
    'banners.field_title':     'Title · multi-language (use \\n for newline)',
    'banners.field_subtitle':  'Subtitle · multi-language',
    'banners.field_cta_label': 'CTA label · multi-language',
    'banners.delete_confirm':  'Delete banner #{id}?',

    'culture.title': 'Culture Collections',
    'culture.sub':   'Cards displayed on the Culture Channel page',
    'culture.new':   '+ New collection',
    'culture.col_mark': 'Mark',
    'culture.col_title_en': 'Title (EN)',
    'culture.col_desc_en':  'Desc (EN)',
    'culture.field_mark':  'Mark (single char)',
    'culture.field_color': 'Background color',
    'culture.field_sort':  'Sort order',
    'culture.field_active':'Active',
    'culture.field_title': 'Title · multi-language',
    'culture.field_desc':  'Description · multi-language',
    'culture.delete_confirm': 'Delete collection?',

    'i18nkeys.title':   'i18n Strings',
    'i18nkeys.count':   'keys · {n} languages',
    'i18nkeys.new':     '+ New key',
    'i18nkeys.filter':  'Filter by key…',
    'i18nkeys.col_key': 'Key',
    'i18nkeys.others':  '{n} / {total} other langs',
    'i18nkeys.field_key': 'Key (namespaced, e.g. hero.title)',
    'i18nkeys.field_values': 'Values · multi-language',
    'i18nkeys.delete_confirm': 'Delete string "{key}"?',
    'i18nkeys.key_required': 'Key required',

    'categories.title':    'Categories',
    'categories.new':      '+ New category',
    'categories.new_top':  '+ New top-level',
    'categories.new_child':'+ Subcategory',
    'categories.count':    'categories',
    'categories.field_code': 'Code',
    'categories.field_parent': 'Parent',
    'categories.field_parent_none': '(Top-level · no parent)',
    'categories.field_sort': 'Sort order',
    'categories.field_active':'Active',
    'categories.field_name': 'Name · multi-language',
    'categories.delete_confirm': 'Delete category {code}?',
    'categories.code_required':  'Code required',
    'categories.cycle':          'Cannot set a descendant as parent — would create a cycle',
    'categories.add_child':      'Add subcategory',
    'categories.expand_all':     'Expand all',
    'categories.collapse_all':   'Collapse all',
    'categories.tree_empty':     'No categories yet — click "New top-level" to start',
    'categories.has_children':   '{n} children',

    'tags.title':          'Tags',
    'tags.sub':            'Tags are orthogonal to categories. Used for virtual filters like gift, limited, handmade, new-arrival.',
    'tags.new':            '+ New tag',
    'tags.count':          'tags',
    'tags.field_code':     'Code',
    'tags.field_name':     'Name · multi-language',
    'tags.field_sort':     'Sort order',
    'tags.field_active':   'Active',
    'tags.delete_confirm': 'Delete tag {code}?',
    'tags.code_required':  'Code required',
    'products.field_tags':      'Product tags (multi-select)',
    'products.field_tags_help': 'Products can carry multiple tags. Tags power virtual channels like Gift, Limited, New.',
    'products.no_tags':         'No tags defined yet',

    'sections.title':       'Site Sections',
    'sections.sub':         'Top navigation. Each section can include categories / subcategories / tags, pick a layout, featured picks, and a default skin.',
    'sections.new':         '+ New section',
    'sections.count':       'sections',
    'sections.field_id':    'Section ID',
    'sections.field_name':  'Name · multi-language',
    'sections.field_sort':  'Sort order',
    'sections.field_active':'Active',
    'sections.field_layout':'Layout template',
    'sections.layout_home': 'Home style (hero + category strips)',
    'sections.layout_culture': 'Culture style',
    'sections.field_skin':  'Default skin',
    'sections.field_skin_none':'(inherit global)',
    'sections.field_accent':'Accent color',
    'sections.field_include':'Include rules',
    'sections.include_help':'Products that match ANY rule below will appear in this section.',
    'sections.include_cats':'Include categories',
    'sections.include_desc': 'w/ descendants',
    'sections.include_tags':'Include tags',
    'sections.include_virtual':'Virtual rules',
    'sections.virtual_sale':'On sale (was_price not null)',
    'sections.virtual_featured':'Featured (featured=true)',
    'sections.virtual_new': 'New (last {n} days)',
    'sections.field_hero':  'Hero / featured area',
    'sections.hero_title':  'Title · multi-language',
    'sections.hero_subtitle':'Subtitle · multi-language',
    'sections.hero_cta':    'CTA label · multi-language',
    'sections.hero_cta_target':'CTA target',
    'sections.hero_product_ids':'Featured product IDs (comma-separated; blank = auto)',
    'sections.hero_picks':      'Picks (cover + copy overrides)',
    'sections.hero_picks_help': 'Each pick maps to a product and can override the cover image, title, subtitle and CTA. Picks appear in the home carousel and section hero.',
    'sections.hero_picks_empty':'No picks yet — storefront will auto-use the first 4 products.',
    'sections.hero_picks_add':  'Add pick',
    'sections.pick_cover':      'Cover image',
    'sections.pick_title':      'Title override · multi-language',
    'sections.pick_subtitle':   'Subtitle override · multi-language',
    'sections.pick_cta':        'CTA label override · multi-language',
    'sections.pick_cta_target_ph':'CTA target (empty = open PDP)',
    'sections.delete_confirm':'Delete section {id}?',
    'sections.id_required': 'ID is required',
    'sections.preview':     'Preview',

    'users.title':       'Registered Users',
    'users.count':       'users',
    'users.kpi_total':   'Total users',
    'users.kpi_verified':'Verified',
    'users.kpi_disabled':'Disabled',
    'users.kpi_week':    'New this week',
    'users.search':      'Search by email or name…',
    'users.filter_all':        'All',
    'users.filter_verified':   'Verified',
    'users.filter_unverified': 'Unverified',
    'users.filter_disabled':   'Disabled',
    'users.col_email':   'Email',
    'users.col_name':    'Name',
    'users.col_locale':  'Locale',
    'users.col_verified':'Email',
    'users.col_created': 'Signed up',
    'users.col_admin':   'Admin',
    'users.no_users':    'No registered users yet.',
    'users.edit':        'Edit',
    'users.resend':      'Resend verify',
    'users.disable':     'Disable',
    'users.enable':      'Enable',
    'users.delete_confirm': 'Delete user {email}? This cannot be undone.',
    'users.edit_title':  'Edit user · {email}',
    'users.field_name':  'Name',
    'users.field_phone': 'Phone',
    'users.field_locale':'Default language',
    'users.field_verified':'Email verified',
    'users.field_active':'Account active',
    'users.field_admin': 'Admin privileges',
    'users.recent_orders': 'Recent orders',
    'users.no_recent_orders': 'No orders yet',
    'users.resent_ok':   'Verification email sent',
    'users.resent_already':'User already verified',
    'users.verified_pill':  'verified',
    'users.unverified_pill':'unverified',

    'mail.not_configured': 'RESEND_API_KEY not configured — no email sent',
    'mail.sent':           'sent',
  },
};

// ------ i18n helpers --------------------------------------------------------
const LangCtx = React.createContext({ lang: 'zh', setLang: () => {} });

function useLang() { return React.useContext(LangCtx); }

function formatMsg(s, vars) {
  if (!vars) return s;
  return s.replace(/\{(\w+)\}/g, (_, k) => (vars[k] != null ? vars[k] : ''));
}

function useT() {
  const { lang } = useLang();
  return (key, vars) => {
    const dict = DICT[lang] || DICT.en;
    const str  = (dict && dict[key]) || (DICT.en && DICT.en[key]) || key;
    return formatMsg(str, vars);
  };
}

// Pick the best value from an i18n JSON object given current lang,
// falling back through zh → en → first non-empty.
function pickI18n(obj, lang) {
  if (!obj) return '';
  if (obj[lang]) return obj[lang];
  if (obj.en) return obj.en;
  if (obj.zh) return obj.zh;
  for (const k of Object.keys(obj)) if (obj[k]) return obj[k];
  return '';
}

// ============================ small primitives =============================
function useToast() {
  const [msg, setMsg] = useState(null);
  const [err, setErr] = useState(false);
  const show = (m, isErr=false) => { setMsg(m); setErr(!!isErr); setTimeout(() => setMsg(null), 2400); };
  const node = msg ? <div className={'toast show ' + (err ? 'err' : '')}>{msg}</div> : null;
  return [show, node];
}

function Modal({ title, children, onClose, onSave, saveLabel, disabled }) {
  const t = useT();
  return (
    <div className="modal-backdrop" onMouseDown={(e)=>{ if (e.target === e.currentTarget) onClose(); }}>
      <div className="modal">
        <div className="modal-head">
          <h3>{title}</h3>
          <span className="x" onClick={onClose}>×</span>
        </div>
        <div className="modal-body">{children}</div>
        <div className="modal-foot">
          <button className="btn" onClick={onClose}>{t('common.cancel')}</button>
          {onSave && <button className="btn primary" onClick={onSave} disabled={disabled}>{saveLabel || t('common.save')}</button>}
        </div>
      </div>
    </div>
  );
}

// Per-component memo of which language is active. Persisted to localStorage so
// it sticks across mounts and tabs in the same admin session.
function I18nField({ value, onChange, textarea = false }) {
  const v = value || {};
  const langs = LANG_ORDER;
  const [active, setActive] = useState(() => {
    try { const s = localStorage.getItem('yy_admin_i18n_lang'); if (s && langs.includes(s)) return s; } catch {}
    return langs[0] || 'en';
  });
  const pick = (l) => {
    setActive(l);
    try { localStorage.setItem('yy_admin_i18n_lang', l); } catch {}
  };
  const set = (lang, text) => onChange({ ...v, [lang]: text });
  return (
    <div className="i18n-tabs-wrap">
      <div className="i18n-tabs" style={{
        display:'flex', gap:0, borderBottom:'1px solid var(--border)',
        marginBottom: 0, flexWrap:'wrap'
      }}>
        {langs.map(l => {
          const has = !!(v[l] && String(v[l]).trim());
          const on  = l === active;
          return (
            <button type="button" key={l} onClick={() => pick(l)}
              title={l + (has ? '' : ' (empty)')}
              style={{
                padding:'4px 10px', fontSize:11,
                fontFamily:'ui-monospace, SFMono-Regular, monospace',
                textTransform:'uppercase', letterSpacing:'0.05em',
                background: on ? '#fff' : 'transparent',
                border: on ? '1px solid var(--border)' : '1px solid transparent',
                borderBottom: on ? '1px solid #fff' : '1px solid transparent',
                marginBottom:-1, borderRadius:'4px 4px 0 0',
                color: on ? 'inherit' : (has ? 'var(--fg-muted)' : 'var(--fg-subtle)'),
                fontWeight: on ? 600 : 400,
                cursor:'pointer', position:'relative',
              }}>
              {l}
              {!has && <span style={{
                position:'absolute', top:3, right:3, width:4, height:4, borderRadius:'50%',
                background:'#d4a017'
              }}/>}
            </button>
          );
        })}
      </div>
      <div style={{ padding: '6px 8px', background:'#fff', border:'1px solid var(--border)', borderTop:'none', borderRadius:'0 4px 4px 4px' }}>
        {textarea
          ? <textarea value={v[active] || ''} onChange={e => set(active, e.target.value)} rows={3} style={{ width:'100%', border:'none', outline:'none', resize:'vertical', font:'inherit', padding:0 }}/>
          : <input    value={v[active] || ''} onChange={e => set(active, e.target.value)} style={{ width:'100%', border:'none', outline:'none', font:'inherit', padding:0 }}/>}
      </div>
    </div>
  );
}

function Field({ label, children, full }) {
  return (
    <label className={'field' + (full ? ' full' : '')}>
      <span className="l">{label}</span>
      {children}
    </label>
  );
}

// Multi-checkbox list for channels. value = array of ids.
function ChannelsPicker({ channels, value, onChange }) {
  const v = Array.isArray(value) ? value : [];
  const t = useT();
  const { lang } = useLang();
  function toggle(id) {
    if (v.includes(id)) onChange(v.filter(x => x !== id));
    else onChange([...v, id]);
  }
  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 8, paddingTop: 4 }}>
      {channels.map(c => (
        <label key={c.id} style={{
          display:'flex', alignItems:'center', gap:8, padding:'6px 10px',
          border:'1px solid var(--border)', borderRadius:4, cursor:'pointer',
          background: v.includes(c.id) ? '#f7f3ea' : 'transparent',
        }}>
          <input type="checkbox" checked={v.includes(c.id)} onChange={()=>toggle(c.id)}/>
          <span style={{ fontSize: 13 }}>
            {c.flag ? c.flag + ' ' : ''}
            <strong className="mono" style={{ fontFamily:'var(--font-mono)', fontSize:12 }}>{c.id}</strong>
            <span style={{ color:'var(--fg-muted)', marginLeft: 6 }}>{pickI18n(c.name_i18n, lang)}</span>
          </span>
        </label>
      ))}
      {channels.length === 0 && <div style={{ color:'var(--fg-muted)' }}>{t('common.empty')}</div>}
    </div>
  );
}

// ============================ dashboard =====================================
function Dashboard({ goto }) {
  const t = useT();
  const { lang } = useLang();
  const [stats, setStats] = useState(null);
  const [recent, setRecent] = useState([]);

  useEffect(() => {
    (async () => {
      const [s, o] = await Promise.all([YY_ADMIN.get('/stats'), YY_ADMIN.get('/orders?limit=5')]);
      setStats(s); setRecent(o);
    })();
  }, []);

  return (
    <>
      <div className="page-head">
        <div><h1>{t('dash.title')}</h1><div className="sub">{t('dash.sub')}</div></div>
      </div>

      <div className="kpis">
        <div className="kpi"><div className="l">{t('dash.kpi_products')}</div><div className="v">{stats?.products ?? '—'}</div><div className="s">{t('dash.active_skus')}</div></div>
        <div className="kpi"><div className="l">{t('dash.kpi_orders')}</div><div className="v">{stats?.orders ?? '—'}</div><div className="s">{t('dash.alltime')}</div></div>
        <div className="kpi"><div className="l">{t('dash.kpi_revenue')}</div><div className="v">${stats ? Number(stats.revenue).toFixed(2) : '—'}</div><div className="s">{t('dash.lifetime')}</div></div>
        <div className="kpi"><div className="l">{t('dash.kpi_channels')}</div><div className="v">{stats?.channels ?? '—'}</div><div className="s">{t('dash.channels_live')}</div></div>
      </div>

      <div className="panel">
        <div className="panel-head">
          <h2>{t('dash.recent_orders')}</h2>
          <a className="link" onClick={() => goto('orders')}>{t('dash.view_all')}</a>
        </div>
        <div className="panel-body">
          {recent.length === 0 ? <div className="empty">{t('dash.no_orders')}</div> :
            <table className="t">
              <thead><tr>
                <th>{t('dash.col_order')}</th><th>{t('dash.col_email')}</th><th>{t('dash.col_channel')}</th>
                <th>{t('dash.col_status')}</th><th>{t('dash.col_total')}</th><th>{t('dash.col_placed')}</th>
              </tr></thead>
              <tbody>
                {recent.map(o => (
                  <tr key={o.id}>
                    <td className="mono">{o.order_no}</td>
                    <td>{o.email}</td>
                    <td>{o.channel_id || '—'}</td>
                    <td><span className={'pill ' + (o.status === 'paid' || o.status === 'shipped' ? 'ok' : o.status === 'cancelled' ? 'off' : 'warn')}>{t('status.' + o.status) || o.status}</span></td>
                    <td>${Number(o.total).toFixed(2)} {o.currency}</td>
                    <td>{new Date(o.created_at).toLocaleString(lang === 'zh' ? 'zh-CN' : 'en-US')}</td>
                  </tr>
                ))}
              </tbody>
            </table>}
        </div>
      </div>
    </>
  );
}

// ============================ products ======================================
// Pick the best thumbnail URL for a product row (DB-shape).
function productThumb(p) {
  if (p.image_url) return p.image_url;
  const g = p.gallery_urls || p.gallery;
  if (Array.isArray(g) && g.length) return g[0];
  return null;
}

// Build the URL that deep-links into the storefront PDP for a product.
function storefrontPdpUrl(p, lang) {
  const qs = new URLSearchParams();
  qs.set('screen', 'pdp');
  qs.set('productId', p.id);
  if (lang) qs.set('lang', lang);
  // Pick a channel the product is visible in (empty allowlist = everywhere).
  const ch = Array.isArray(p.channels) ? p.channels : [];
  if (ch.length > 0) qs.set('channel', ch[0]);
  return '/?' + qs.toString();
}

function ProductsPage({ toast }) {
  const t = useT();
  const { lang } = useLang();
  const [rows, setRows] = useState(null);
  const [cats, setCats] = useState([]);
  const [channels, setChannels] = useState([]);
  const [editing, setEditing] = useState(null);      // product object (null = closed)
  const [creating, setCreating] = useState(false);
  const [fCat, setFCat] = useState('');
  const [fCh,  setFCh]  = useState('');
  const [showThumbs, setShowThumbs] = useState(() => {
    try { return localStorage.getItem('yy_admin_show_thumbs') !== '0'; } catch { return true; }
  });
  useEffect(() => {
    try { localStorage.setItem('yy_admin_show_thumbs', showThumbs ? '1' : '0'); } catch {}
  }, [showThumbs]);

  const [allTags, setAllTags] = useState([]);
  const reload = async () => {
    const [p, c, ch, tg] = await Promise.all([
      YY_ADMIN.get('/products'),
      YY_ADMIN.get('/categories'),
      YY_ADMIN.get('/channels'),
      YY_ADMIN.get('/tags').catch(() => []),
    ]);
    setRows(p); setCats(c); setChannels(ch); setAllTags(tg);
  };
  useEffect(() => { reload(); }, []);

  const filtered = (rows || []).filter(p => {
    if (fCat && p.category_code !== fCat) return false;
    if (fCh) {
      const ch = Array.isArray(p.channels) ? p.channels : [];
      if (ch.length > 0 && !ch.includes(fCh)) return false;
    }
    return true;
  });

  async function remove(id) {
    if (!confirm(t('products.delete_confirm', { id }))) return;
    try { await YY_ADMIN.del('/products/' + id); toast(t('common.deleted')); reload(); } catch (e) { toast(e.message, true); }
  }

  async function toggle(p) {
    try { await YY_ADMIN.put('/products/' + p.id, { active: !p.active }); reload(); } catch (e) { toast(e.message, true); }
  }

  function channelsCell(p) {
    const ch = Array.isArray(p.channels) ? p.channels : [];
    if (ch.length === 0) return <span style={{ color: 'var(--fg-muted)', fontSize: 12 }}>{t('products.visible_all')}</span>;
    return <span style={{ fontSize: 12 }}>{ch.map(id => <span key={id} className="pill" style={{ marginRight: 4 }}>{id}</span>)}</span>;
  }

  return (
    <>
      <div className="page-head">
        <div><h1>{t('products.title')}</h1><div className="sub">{rows ? `${filtered.length} / ${rows.length} ${t('products.count')}` : '…'}</div></div>
        <div className="actions">
          <button className="btn" onClick={() => setShowThumbs(s => !s)} title={showThumbs ? t('products.hide_thumbs') : t('products.show_thumbs')}>
            {showThumbs ? '🖼 ' + t('products.hide_thumbs') : '🖼 ' + t('products.show_thumbs')}
          </button>
          <select value={fCat} onChange={e=>setFCat(e.target.value)} style={{ padding: '8px 12px', border: '1px solid var(--border-strong)', borderRadius: 4, background: 'var(--bg-alt)' }}>
            <option value="">{t('products.filter_cat')}</option>
            {cats.map(c => <option key={c.code} value={c.code}>{pickI18n(c.name_i18n, lang) || c.code}</option>)}
          </select>
          <select value={fCh} onChange={e=>setFCh(e.target.value)} style={{ padding: '8px 12px', border: '1px solid var(--border-strong)', borderRadius: 4, background: 'var(--bg-alt)' }}>
            <option value="">{t('products.filter_ch')}</option>
            {channels.map(c => <option key={c.id} value={c.id}>{(c.flag ? c.flag + ' ' : '') + c.id}</option>)}
          </select>
          <button className="btn primary" onClick={() => setCreating(true)}>{t('products.new')}</button>
        </div>
      </div>

      <div className="panel">
        <div className="panel-body">
          {rows === null ? <div className="loading">{t('common.loading')}</div> :
           filtered.length === 0 ? <div className="empty">{t('common.empty')}</div> :
           <table className="t">
             <thead><tr>
               {showThumbs && <th style={{ width: 64 }}>{t('products.col_thumb')}</th>}
               <th>{t('common.id')}</th>
               <th>{t('products.col_name')}</th>
               <th>{t('products.col_category')}</th>
               <th>{t('products.col_price')}</th>
               <th>{t('products.col_style')}</th>
               <th>{t('common.color')}</th>
               <th>{t('products.col_channels')}</th>
               <th>{t('common.state')}</th>
               <th></th>
             </tr></thead>
             <tbody>
               {filtered.map(p => {
                 const thumb = productThumb(p);
                 const pdpHref = storefrontPdpUrl(p, lang);
                 const nameZh = p.name_i18n?.zh || '';
                 const nameEn = p.name_i18n?.en || '';
                 return (
                 <tr key={p.id}>
                   {showThumbs && (
                     <td style={{ width: 64, padding: '6px 4px' }}>
                       <a href={pdpHref} target="_blank" rel="noopener" title={t('products.view_pdp')}>
                         {thumb ? (
                           <img src={thumb} alt={p.id} loading="lazy"
                                style={{ width: 56, height: 72, objectFit: 'cover', borderRadius: 3, border: '1px solid var(--border)', display: 'block', background: p.color || '#eee' }}/>
                         ) : (
                           <div style={{ width: 56, height: 72, borderRadius: 3, border: '1px dashed var(--border)',
                                         display:'flex', alignItems:'center', justifyContent:'center',
                                         fontSize: 10, color:'var(--fg-subtle)', background: p.color || '#eee' }}>no img</div>
                         )}
                       </a>
                     </td>
                   )}
                   <td className="mono">{p.id}</td>
                   <td style={{ maxWidth: 320 }}>
                     <a href={pdpHref} target="_blank" rel="noopener" style={{ color: 'inherit', textDecoration: 'none', borderBottom: '1px dotted var(--fg-subtle)' }}>
                       {pickI18n(p.name_i18n, lang) || '—'}
                     </a>
                     <div style={{ color: 'var(--fg-muted)', fontSize: 12 }}>
                       {lang === 'zh' ? nameEn : nameZh}
                       {p.subcategory && <span style={{ marginLeft: 6, fontSize: 11, color: 'var(--fg-subtle)' }}>· {p.subcategory}</span>}
                     </div>
                   </td>
                   <td>{pickI18n(cats.find(c => c.code === p.category_code)?.name_i18n, lang) || p.category_code || '—'}</td>
                   <td>
                     ${Number(p.price).toFixed(2)}
                     {p.was_price && <span style={{ color: 'var(--fg-subtle)', textDecoration: 'line-through', marginLeft: 6, fontSize: 12 }}>${Number(p.was_price).toFixed(2)}</span>}
                   </td>
                   <td>{p.img_style}</td>
                   <td><span className="color-pill"><span className="swatch-dot" style={{ background: p.color }}/>{p.color}</span></td>
                   <td>{channelsCell(p)}</td>
                   <td>
                     <span className={'pill ' + (p.active ? 'ok' : 'off')}>{p.active ? t('common.active') : t('common.off')}</span>
                     {p.featured && <span className="pill warn" style={{ marginLeft: 6 }}>{t('products.featured')}</span>}
                   </td>
                   <td>
                     <div className="row-actions">
                       <a className="btn sm" href={pdpHref} target="_blank" rel="noopener"
                          style={{ textDecoration: 'none', display: 'inline-flex', alignItems: 'center' }}>
                         {t('products.view_pdp')} ↗
                       </a>
                       <button onClick={() => setEditing(p)}>{t('common.edit')}</button>
                       <button onClick={() => toggle(p)}>{p.active ? t('common.hide') : t('common.show')}</button>
                       <button className="danger" onClick={() => remove(p.id)}>{t('common.delete')}</button>
                     </div>
                   </td>
                 </tr>
                 );
               })}
             </tbody>
           </table>}
        </div>
      </div>

      {(editing || creating) && (
        <ProductEditor
          product={editing}
          creating={creating}
          cats={cats}
          allTags={allTags}
          channels={channels}
          onClose={() => { setEditing(null); setCreating(false); }}
          onSaved={async () => { setEditing(null); setCreating(false); await reload(); toast(t('common.saved')); }}
          onError={(m) => toast(m, true)}
          toast={toast}
        />
      )}
    </>
  );
}

// Upload a File to the server as a base64 data URL. productId + slot tell the
// server where to put it (slot = "primary" | 0 | 1 | 2 | 3).
function readFileAsDataURL(file) {
  return new Promise((resolve, reject) => {
    const r = new FileReader();
    r.onload  = () => resolve(r.result);
    r.onerror = () => reject(r.error || new Error('read_failed'));
    r.readAsDataURL(file);
  });
}

// Modal that shows / edits an AI image prompt before calling the generator.
function PromptModal({ title, initialPrompt, loading, onClose, onConfirm }) {
  const t = useT();
  const [prompt, setPrompt] = useState(initialPrompt || '');
  useEffect(() => { setPrompt(initialPrompt || ''); }, [initialPrompt]);
  return (
    <Modal
      title={title || t('products.prompt_title')}
      onClose={onClose}
      onSave={() => onConfirm(prompt)}
      saveLabel={t('products.prompt_confirm')}
      disabled={loading}
    >
      <div style={{ fontSize: 12, color: 'var(--fg-muted)', marginBottom: 8 }}>{t('products.prompt_hint')}</div>
      <textarea
        value={prompt}
        onChange={(e) => setPrompt(e.target.value)}
        rows={10}
        style={{
          width: '100%',
          fontFamily: '"JetBrains Mono", ui-monospace, monospace',
          fontSize: 12,
          padding: 10,
          lineHeight: 1.55,
          border: '1px solid var(--border)',
          borderRadius: 4,
        }}
      />
    </Modal>
  );
}

// Gallery editor — primary image + arbitrary-length gallery array. Each slot
// offers preview / URL field / upload / AI (via PromptModal) / clear. The
// gallery array is never capped; users add slots with "+ Add gallery image"
// and reorder / remove individual slots inline.
function GalleryEditor({ productId, creating, imageUrl, galleryUrls, onChange, onError, toast }) {
  const t = useT();
  const [busy, setBusy] = useState(null);   // "upload-primary" | "gen-2" | "ai-prompt"
  const [promptFor, setPromptFor] = useState(null); // { slot, text }
  const fileRefs = useRef({});

  const gallery = Array.isArray(galleryUrls) ? galleryUrls : [];

  // --- slot helpers -------------------------------------------------------
  const setPrimary = (u) => onChange({ imageUrl: u, galleryUrls: gallery });
  const setGallery = (arr) => onChange({ imageUrl, galleryUrls: arr });
  const setGalleryAt = (i, u) => {
    const g = gallery.slice();
    while (g.length <= i) g.push(null);
    g[i] = u;
    setGallery(g);
  };
  const addSlot = () => setGallery([...gallery, null]);
  const removeSlot = (i) => {
    const g = gallery.slice();
    g.splice(i, 1);
    setGallery(g);
    toast(t('products.cleared'));
  };
  const moveSlot = (i, delta) => {
    const j = i + delta;
    if (j < 0 || j >= gallery.length) return;
    const g = gallery.slice();
    [g[i], g[j]] = [g[j], g[i]];
    setGallery(g);
  };
  const getUrl = (slotKey) => slotKey === 'primary' ? imageUrl : (gallery[Number(slotKey)] || null);
  const setUrl = (slotKey, u) => {
    if (slotKey === 'primary') return setPrimary(u);
    return setGalleryAt(Number(slotKey), u);
  };

  // --- upload flow --------------------------------------------------------
  async function handleFile(slotKey, file) {
    if (!file) return;
    if (creating || !productId) return onError(t('products.must_save_first'));
    if (!/^image\//.test(file.type)) return onError('Not an image');
    setBusy('upload-' + slotKey);
    try {
      const dataUrl = await readFileAsDataURL(file);
      const r = await YY_ADMIN.post('/admin/upload-image', {
        productId,
        slot: slotKey === 'primary' ? 'primary' : Number(slotKey),
        dataUrl,
      });
      setUrl(slotKey, r.url + '?v=' + Date.now());
      toast(t('products.upload_ok'));
    } catch (e) { onError(e.message); }
    finally { setBusy(null); }
  }

  // --- AI flow ------------------------------------------------------------
  async function openPrompt(slotKey) {
    if (creating || !productId) return onError(t('products.must_save_first'));
    setBusy('ai-prompt');
    try {
      const qs = slotKey === 'primary' ? 'primary' : String(slotKey);
      const r = await YY_ADMIN.get('/admin/ai-prompt/' + productId + '?slot=' + qs);
      setPromptFor({ slot: slotKey, text: r.prompt });
    } catch (e) { onError(e.message); }
    finally { setBusy(null); }
  }

  async function confirmPrompt(slotKey, finalPrompt) {
    setBusy('gen-' + slotKey);
    try {
      let url;
      if (slotKey === 'primary') {
        const r = await YY_ADMIN.post('/admin/gen-image/' + productId, { prompt: finalPrompt });
        url = r.image_url + '?v=' + Date.now();
      } else {
        const r = await YY_ADMIN.post('/admin/gen-gallery/' + productId, {
          shot: Number(slotKey),
          prompt: finalPrompt,
          force: true,
        });
        url = r.url + '?v=' + Date.now();
      }
      setUrl(slotKey, url);
      toast(t('products.ai_gen_ok'));
    } catch (e) { onError(e.message); }
    finally { setBusy(null); setPromptFor(null); }
  }

  // --- render -------------------------------------------------------------
  const slots = [
    { key: 'primary', label: t('products.slot_primary'), isPrimary: true, index: -1 },
    ...gallery.map((_, i) => ({
      key: String(i),
      label: t('products.slot_n', { n: i + 1 }),
      isPrimary: false,
      index: i,
    })),
  ];

  return (
    <div>
      <div style={{ fontSize: 12, color: 'var(--fg-muted)', marginBottom: 10 }}>{t('products.media_hint')}</div>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 12 }}>
        {slots.map(s => {
          const url = getUrl(s.key);
          const uploadBusy = busy === 'upload-' + s.key;
          const genBusy    = busy === 'gen-'    + s.key;
          return (
            <div key={s.key} style={{
              border: '1px solid var(--border)', borderRadius: 4, padding: 10,
              background: s.isPrimary ? '#faf6ee' : '#fff',
            }}>
              <div style={{
                display: 'flex', alignItems: 'center', justifyContent: 'space-between',
                fontSize: 11, letterSpacing: '0.1em', textTransform: 'uppercase',
                color: 'var(--fg-muted)', marginBottom: 6,
              }}>
                <span>{s.label}</span>
                {!s.isPrimary && (
                  <span style={{ display: 'inline-flex', gap: 2 }}>
                    <button type="button" className="btn sm" title={t('products.move_up')}
                            onClick={() => moveSlot(s.index, -1)}
                            disabled={s.index === 0}
                            style={{ padding: '2px 6px', fontSize: 11, lineHeight: 1 }}>↑</button>
                    <button type="button" className="btn sm" title={t('products.move_down')}
                            onClick={() => moveSlot(s.index, +1)}
                            disabled={s.index === gallery.length - 1}
                            style={{ padding: '2px 6px', fontSize: 11, lineHeight: 1 }}>↓</button>
                    <button type="button" className="btn sm danger" title={t('products.remove')}
                            onClick={() => removeSlot(s.index)}
                            style={{ padding: '2px 7px', fontSize: 11, lineHeight: 1 }}>×</button>
                  </span>
                )}
              </div>

              <div style={{
                width: '100%', aspectRatio: '3 / 4', background: '#eee', borderRadius: 3,
                overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center',
                border: '1px dashed var(--border)', marginBottom: 8,
              }}>
                {url ? (
                  <img src={url} alt={s.label} loading="lazy"
                       style={{ width: '100%', height: '100%', objectFit: 'cover' }}/>
                ) : (
                  <span style={{ color: 'var(--fg-subtle)', fontSize: 11 }}>{t('products.no_image')}</span>
                )}
              </div>

              <input type="text" value={url || ''} placeholder={t('products.paste_url')}
                     onChange={e => setUrl(s.key, e.target.value || null)}
                     style={{ width: '100%', fontSize: 11, padding: '4px 6px', border: '1px solid var(--border)', borderRadius: 3, marginBottom: 6 }}/>

              <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
                <input type="file" accept="image/*" style={{ display: 'none' }}
                       ref={r => { fileRefs.current[s.key] = r; }}
                       onChange={e => { const f = e.target.files?.[0]; e.target.value = ''; handleFile(s.key, f); }}/>
                <button type="button" className="btn sm" disabled={uploadBusy}
                        onClick={() => fileRefs.current[s.key]?.click()}>
                  {uploadBusy ? '…' : t('products.upload')}
                </button>
                <button type="button" className="btn sm" disabled={genBusy}
                        onClick={() => openPrompt(s.key)} title="AI">
                  {genBusy ? '…' : 'AI'}
                </button>
                <button type="button" className="btn sm danger"
                        onClick={() => { setUrl(s.key, null); toast(t('products.cleared')); }}>×</button>
              </div>
            </div>
          );
        })}
      </div>

      <div style={{ marginTop: 12 }}>
        <button type="button" className="btn sm" onClick={addSlot}>{t('products.add_slot')}</button>
      </div>

      {promptFor && (
        <PromptModal
          initialPrompt={promptFor.text}
          loading={busy && busy.startsWith('gen-')}
          onClose={() => setPromptFor(null)}
          onConfirm={(finalPrompt) => confirmPrompt(promptFor.slot, finalPrompt)}
        />
      )}
    </div>
  );
}

// --- AssetSlot ----------------------------------------------------------
// Reusable compact image slot: preview + URL input + upload / AI / clear
// buttons. Unlike GalleryEditor, this doesn't touch the products row — it
// POSTs to /api/admin/upload-asset & /api/admin/gen-asset, which return a
// plain URL for the caller to store wherever it wants.
function AssetSlot({ url, onChange, onError, toast, kind, assetKey, aspect, label, defaultPromptFn }) {
  const t = useT();
  const [busy, setBusy] = useState(null); // 'upload' | 'ai' | 'prompt-load'
  const [pendingPrompt, setPendingPrompt] = useState('');
  const [promptOpen, setPromptOpen] = useState(false);
  const fileRef = useRef(null);

  async function handleFile(file) {
    if (!file) return;
    if (!/^image\//.test(file.type)) return onError('Not an image');
    setBusy('upload');
    try {
      const dataUrl = await readFileAsDataURL(file);
      const r = await YY_ADMIN.post('/admin/upload-asset', {
        dataUrl, kind: kind || 'asset', key: assetKey || '',
      });
      onChange(r.url);
      if (toast) toast(t('products.upload_ok'));
    } catch (e) { onError(e.message); }
    finally { setBusy(null); }
  }

  async function openPrompt() {
    // If caller supplied a generator, pre-fetch a default before opening the modal.
    if (typeof defaultPromptFn === 'function') {
      setBusy('prompt-load');
      try {
        const draft = await defaultPromptFn();
        setPendingPrompt(typeof draft === 'string' ? draft : '');
      } catch (e) {
        // Soft-fail: open modal empty if prompt generation fails.
        setPendingPrompt('');
        if (onError) onError(e.message || 'prompt_fail');
      } finally {
        setBusy(null);
      }
    } else {
      setPendingPrompt('');
    }
    setPromptOpen(true);
  }

  async function runAI(finalPrompt) {
    setBusy('ai');
    try {
      const r = await YY_ADMIN.post('/admin/gen-asset', {
        prompt: finalPrompt, kind: kind || 'asset', key: assetKey || '',
      });
      onChange(r.url);
      if (toast) toast(t('products.ai_gen_ok'));
    } catch (e) { onError(e.message); }
    finally { setBusy(null); setPromptOpen(false); }
  }

  return (
    <div style={{ border: '1px solid var(--border)', borderRadius: 4, padding: 8, background: '#fff' }}>
      {label ? (
        <div style={{ fontSize: 11, color: 'var(--fg-muted)', marginBottom: 4, letterSpacing: '0.06em', textTransform: 'uppercase' }}>
          {label}
        </div>
      ) : null}
      <div style={{
        width: '100%', aspectRatio: (aspect || '3 / 4'), background: '#eee', borderRadius: 3,
        overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center',
        border: '1px dashed var(--border)', marginBottom: 6,
      }}>
        {url ? (
          <img src={url} alt="" loading="lazy" style={{ width: '100%', height: '100%', objectFit: 'cover' }}/>
        ) : (
          <span style={{ color: 'var(--fg-subtle)', fontSize: 11 }}>{t('products.no_image')}</span>
        )}
      </div>
      <input type="text" value={url || ''} placeholder={t('products.paste_url')}
             onChange={e => onChange(e.target.value || null)}
             style={{ width: '100%', fontSize: 11, padding: '4px 6px', border: '1px solid var(--border)', borderRadius: 3, marginBottom: 6 }}/>
      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
        <input type="file" accept="image/*" style={{ display: 'none' }} ref={fileRef}
               onChange={e => { const f = e.target.files?.[0]; e.target.value = ''; handleFile(f); }}/>
        <button type="button" className="btn sm" disabled={busy === 'upload'}
                onClick={() => fileRef.current?.click()}>
          {busy === 'upload' ? '…' : t('products.upload')}
        </button>
        <button type="button" className="btn sm" disabled={busy === 'ai' || busy === 'prompt-load'}
                onClick={openPrompt}>
          {busy === 'prompt-load' ? '…' : 'AI'}
        </button>
        <button type="button" className="btn sm danger"
                onClick={() => { onChange(null); if (toast) toast(t('products.cleared')); }}>×</button>
      </div>
      {promptOpen && (
        <PromptModal
          initialPrompt={pendingPrompt}
          loading={busy === 'ai'}
          onClose={() => setPromptOpen(false)}
          onConfirm={(final) => runAI(final)}
        />
      )}
    </div>
  );
}

// --- ColorVariantsEditor ------------------------------------------------
// Edits `product.colors` — an array of variants, each of which optionally
// owns its own image_url and gallery_urls. When empty, the PDP falls back to
// product-level media; when a color has image_url or gallery_urls, those
// replace the product defaults while that color is selected.
function ColorVariantsEditor({ productId, productForm, value, onChange, onError, toast }) {
  const t = useT();
  const variants = Array.isArray(value) ? value : [];

  // Migrate any legacy plain-string entries at render time so the editor
  // always sees object-shaped variants.
  const normalized = variants.map(v => (typeof v === 'string' ? { hex: v } : (v || {})));

  const set = (arr) => onChange(arr);
  const setAt = (i, patch) => {
    const next = normalized.slice();
    next[i] = { ...next[i], ...patch };
    set(next);
  };
  const add = () => set([...normalized, { hex: '#888888', name_i18n: {}, image_url: null, gallery_urls: [] }]);
  const remove = (i) => { const n = normalized.slice(); n.splice(i, 1); set(n); };
  const move = (i, delta) => {
    const j = i + delta;
    if (j < 0 || j >= normalized.length) return;
    const n = normalized.slice();
    [n[i], n[j]] = [n[j], n[i]];
    set(n);
  };

  const setGalleryAt = (i, gi, u) => {
    const g = Array.isArray(normalized[i].gallery_urls) ? normalized[i].gallery_urls.slice() : [];
    while (g.length <= gi) g.push(null);
    g[gi] = u;
    setAt(i, { gallery_urls: g });
  };
  const addGallerySlot = (i) => {
    const g = Array.isArray(normalized[i].gallery_urls) ? normalized[i].gallery_urls.slice() : [];
    g.push(null);
    setAt(i, { gallery_urls: g });
  };
  const removeGallerySlot = (i, gi) => {
    const g = Array.isArray(normalized[i].gallery_urls) ? normalized[i].gallery_urls.slice() : [];
    g.splice(gi, 1);
    setAt(i, { gallery_urls: g });
  };

  return (
    <div>
      <div style={{ fontSize: 12, color: 'var(--fg-muted)', marginBottom: 10 }}>{t('products.colors_hint')}</div>

      {normalized.length === 0 ? (
        <div style={{ fontSize: 12, color: 'var(--fg-subtle)', padding: '12px 0' }}>{t('products.colors_empty')}</div>
      ) : (
        <div style={{ display: 'grid', gap: 14 }}>
          {normalized.map((c, i) => {
            const gallery = Array.isArray(c.gallery_urls) ? c.gallery_urls : [];
            const key = productId ? (productId + '-c' + (i + 1)) : ('c' + (i + 1));
            return (
              <div key={i} style={{ border: '1px solid var(--border)', borderRadius: 4, padding: 12, background: '#faf6ee' }}>
                {/* Header: swatch + hex + reorder + remove */}
                <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
                  <div style={{
                    width: 26, height: 26, borderRadius: '50%',
                    background: c.hex || '#ccc',
                    border: '1px solid var(--border-strong)',
                    boxShadow: '0 0 0 2px #fff inset',
                  }}/>
                  <input
                    type="text" value={c.hex || ''}
                    onChange={e => setAt(i, { hex: e.target.value })}
                    placeholder="#c83232"
                    style={{ width: 110, fontFamily: 'ui-monospace, monospace', fontSize: 12 }}/>
                  <input
                    type="color" value={c.hex && /^#[0-9a-f]{6}$/i.test(c.hex) ? c.hex : '#cccccc'}
                    onChange={e => setAt(i, { hex: e.target.value })}
                    style={{ width: 36, height: 26, padding: 0, border: '1px solid var(--border)', borderRadius: 3 }}/>
                  <div style={{ flex: 1 }}/>
                  <div style={{ display: 'inline-flex', gap: 2 }}>
                    <button type="button" className="btn sm" disabled={i === 0}
                            onClick={() => move(i, -1)} style={{ padding: '2px 6px', fontSize: 11 }}>↑</button>
                    <button type="button" className="btn sm" disabled={i === normalized.length - 1}
                            onClick={() => move(i, +1)} style={{ padding: '2px 6px', fontSize: 11 }}>↓</button>
                    <button type="button" className="btn sm danger"
                            onClick={() => remove(i)} style={{ padding: '2px 7px', fontSize: 11 }}>×</button>
                  </div>
                </div>

                {/* Name per-language */}
                <div style={{ marginBottom: 10 }}>
                  <div style={{ fontSize: 11, color: 'var(--fg-muted)', marginBottom: 4, letterSpacing: '0.06em', textTransform: 'uppercase' }}>
                    {t('products.color_name')}
                  </div>
                  <I18nField value={c.name_i18n || {}} onChange={v => setAt(i, { name_i18n: v })}/>
                </div>

                {/* Per-variant main + gallery */}
                <div style={{
                  display: 'grid',
                  gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
                  gap: 10,
                }}>
                  <AssetSlot
                    label={t('products.slot_primary')}
                    kind="colors"
                    assetKey={key + '-primary'}
                    url={c.image_url || null}
                    onChange={(u) => setAt(i, { image_url: u })}
                    onError={onError}
                    toast={toast}
                    defaultPromptFn={async () => {
                      if (!productId) return '';
                      const r = await YY_ADMIN.post('/admin/ai-prompt-color', {
                        productId, colorIndex: i, shot: -1,
                      });
                      return r.prompt || '';
                    }}
                  />
                  {gallery.map((u, gi) => (
                    <div key={gi} style={{ position: 'relative' }}>
                      <AssetSlot
                        label={t('products.slot_n', { n: gi + 1 })}
                        kind="colors"
                        assetKey={key + '-' + (gi + 1)}
                        url={u || null}
                        defaultPromptFn={async () => {
                          if (!productId) return '';
                          const r = await YY_ADMIN.post('/admin/ai-prompt-color', {
                            productId, colorIndex: i, shot: gi,
                          });
                          return r.prompt || '';
                        }}
                        onChange={(nu) => setGalleryAt(i, gi, nu)}
                        onError={onError}
                        toast={toast}
                      />
                      <button type="button" className="btn sm danger"
                              onClick={() => removeGallerySlot(i, gi)}
                              style={{ position: 'absolute', top: 2, right: 2, padding: '1px 6px', fontSize: 10 }}>−</button>
                    </div>
                  ))}
                </div>

                <div style={{ marginTop: 10 }}>
                  <button type="button" className="btn sm" onClick={() => addGallerySlot(i)}>
                    {t('products.add_slot')}
                  </button>
                </div>
              </div>
            );
          })}
        </div>
      )}

      <div style={{ marginTop: 12 }}>
        <button type="button" className="btn sm" onClick={add}>+ {t('products.add_color')}</button>
      </div>
    </div>
  );
}

// --- ReferenceUploader ----------------------------------------------------
// Lets the merchant upload N real product photos. These never show on the
// storefront — they're only fed into the AI batch generator as visual context.
function ReferenceUploader({ productId, value, onChange, onError, toast }) {
  const t = useT();
  const list = Array.isArray(value) ? value : [];
  const fileRef = useRef(null);
  const [busy, setBusy] = useState(false);

  const setAt = (i, u) => {
    const next = list.slice();
    if (u == null) next.splice(i, 1);
    else next[i] = u;
    onChange(next);
  };
  const append = (u) => onChange([...list, u]);

  async function handlePicked(file) {
    if (!file) return;
    if (!/^image\//.test(file.type)) return onError('Not an image');
    setBusy(true);
    try {
      const dataUrl = await readFileAsDataURL(file);
      const r = await YY_ADMIN.post('/admin/upload-asset', {
        dataUrl,
        kind: 'reference',
        key: (productId || 'new') + '-ref' + (list.length + 1),
      });
      append(r.url);
      if (toast) toast(t('products.upload_ok'));
    } catch (e) { onError(e.message); }
    finally { setBusy(false); }
  }

  return (
    <div>
      <div style={{ fontSize: 12, color:'var(--fg-muted)', marginBottom: 10 }}>
        {t('products.references_hint')}
      </div>
      {list.length === 0 ? (
        <div style={{ fontSize:12, color:'var(--fg-subtle)', padding:'8px 0' }}>
          {t('products.references_empty')}
        </div>
      ) : (
        <div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(140px, 1fr))', gap:10, marginBottom:10 }}>
          {list.map((u, i) => (
            <div key={i} style={{ position:'relative', border:'1px solid var(--border)', borderRadius:4, padding:6, background:'#fff' }}>
              <div style={{
                width:'100%', aspectRatio:'1 / 1', background:'#eee', borderRadius:3,
                overflow:'hidden', display:'flex', alignItems:'center', justifyContent:'center',
                border:'1px dashed var(--border)',
              }}>
                <img src={u} alt="" loading="lazy" style={{ width:'100%', height:'100%', objectFit:'cover' }}/>
              </div>
              <button type="button" className="btn sm danger"
                      onClick={() => setAt(i, null)}
                      style={{ position:'absolute', top:4, right:4, padding:'1px 7px', fontSize:11 }}>×</button>
            </div>
          ))}
        </div>
      )}
      <input type="file" accept="image/*" style={{ display:'none' }} ref={fileRef}
             onChange={e => { const f = e.target.files?.[0]; e.target.value = ''; handlePicked(f); }}/>
      <button type="button" className="btn sm" disabled={busy} onClick={() => fileRef.current?.click()}>
        {busy ? '…' : t('products.references_add')}
      </button>
    </div>
  );
}

// --- BatchAIButton --------------------------------------------------------
// Opens a modal that lets the user pick which color variants and how many
// images per color. POSTs /admin/gen-color-batch, polls progress, refreshes
// the parent on completion.
function BatchAIButton({ productId, productForm, onCompleted, onError, toast }) {
  const t = useT();
  const colors = Array.isArray(productForm?.colors) ? productForm.colors : [];
  const refs   = Array.isArray(productForm?.reference_urls) ? productForm.reference_urls.filter(Boolean) : [];
  const [open, setOpen] = useState(false);
  const [picked, setPicked] = useState(() => colors.map((_, i) => i));
  const [perColor, setPerColor] = useState(4);
  const [modelCount, setModelCount] = useState(0);
  const [chosenRef, setChosenRef] = useState(refs[0] || '');
  const [shotPrompts, setShotPrompts] = useState([]); // length = perColor; each entry is a full Chinese prompt
  const [genBusy, setGenBusy] = useState(false);      // AI is writing prompts
  const [job, setJob] = useState(null);
  const [busy, setBusy] = useState(false);
  const pollRef = useRef(null);

  const noRef = refs.length === 0;
  const noColors = colors.length === 0;

  // Build the "color images you can also use as reference" pool.
  const colorPool = [];
  colors.forEach((c, ci) => {
    const cn = (c?.name_i18n && (c.name_i18n.zh || c.name_i18n.en || Object.values(c.name_i18n)[0])) || c?.hex || ('#' + (ci+1));
    if (c?.image_url) colorPool.push({ url: c.image_url, label: cn + ' · 主图' });
    if (Array.isArray(c?.gallery_urls)) {
      c.gallery_urls.filter(Boolean).forEach((u, gi) => colorPool.push({ url: u, label: cn + ' · ' + (gi+1) }));
    }
  });

  // Reset selection when colors / refs change
  useEffect(() => { setPicked(colors.map((_, i) => i)); }, [colors.length]);
  useEffect(() => {
    // Auto-pick first reference if the chosen one is no longer present.
    if (!chosenRef || !(refs.includes(chosenRef) || colorPool.some(x => x.url === chosenRef))) {
      setChosenRef(refs[0] || colorPool[0]?.url || '');
    }
  }, [refs.length, colorPool.length]);  // eslint-disable-line

  const N = Math.max(1, Math.min(8, Number(perColor) || 1));
  const Mraw = Math.max(0, Math.min(N, Number(modelCount) || 0));
  const productCount = N - Mraw;

  // Resize the prompt list whenever N changes — pad / trim, preserve existing edits.
  useEffect(() => {
    setShotPrompts(prev => {
      const next = (prev || []).slice(0, N);
      while (next.length < N) next.push('');
      return next;
    });
  }, [N]);

  // Generate N Chinese prompts via AI in one shot.
  async function generatePromptsViaAI() {
    if (!productId || genBusy) return;
    setGenBusy(true);
    try {
      const r = await YY_ADMIN.post('/admin/gen-prompts-for-shots', {
        productId,
        totalShots: N,
        modelShots: Mraw,
        referenceUrl: chosenRef || undefined,
      });
      if (Array.isArray(r.prompts) && r.prompts.length === N) {
        setShotPrompts(r.prompts.map(s => String(s || '')));
        if (toast) toast(t('products.ai_gen_ok'));
      } else {
        onError(r.error || 'bad_format');
      }
    } catch (e) {
      onError(e.message);
    } finally {
      setGenBusy(false);
    }
  }

  function togglePick(i) {
    setPicked(picked.includes(i) ? picked.filter(x => x !== i) : [...picked, i].sort((a,b)=>a-b));
  }
  function pickAll()  { setPicked(colors.map((_, i) => i)); }
  function pickNone() { setPicked([]); }

  async function start() {
    if (!productId) { onError(t('products.batch_save_first')); return; }
    if (noRef) { onError(t('products.batch_no_ref')); return; }
    if (noColors) { onError(t('products.batch_no_colors')); return; }
    if (picked.length === 0) return;
    if (!chosenRef) { onError(t('products.batch_no_ref')); return; }
    const promptsReady = Array.isArray(shotPrompts) && shotPrompts.length === N
      && shotPrompts.every(s => s && String(s).trim().length > 0);
    if (!promptsReady) { onError(t('products.batch_prompts_required')); return; }
    setBusy(true);
    try {
      const r = await YY_ADMIN.post('/admin/gen-color-batch', {
        productId, colorIndices: picked, imagesPerColor: N,
        referenceUrl: chosenRef,
        modelCount: Mraw,
        prompts: shotPrompts,
      });
      if (!r.jobId) throw new Error(r.error || 'no_job_id');
      setJob({ jobId: r.jobId, total: r.total, done: 0, complete: false, errors: [] });
      const tick = async () => {
        try {
          const j = await YY_ADMIN.get('/admin/gen-color-batch/' + r.jobId);
          setJob(j);
          if (j.complete) {
            clearInterval(pollRef.current); pollRef.current = null;
            setBusy(false);
            try {
              const fresh = await YY_ADMIN.get('/products/' + productId);
              if (fresh && Array.isArray(fresh.colors)) onCompleted(fresh.colors);
            } catch {}
            if (toast) toast(t('products.ai_gen_ok'));
          }
        } catch (e) { /* keep polling */ }
      };
      pollRef.current = setInterval(tick, 2500);
      tick();
    } catch (e) {
      onError(e.message);
      setBusy(false);
    }
  }

  useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []);

  const pct = job && job.total ? Math.floor((job.done / job.total) * 100) : 0;
  const disabled = !productId || noRef || noColors;
  const tooltip  = !productId ? t('products.batch_save_first')
                  : noRef     ? t('products.batch_no_ref')
                  : noColors  ? t('products.batch_no_colors')
                  : '';

  return (
    <div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px dashed var(--border)' }}>
      <button type="button" className="btn primary"
              onClick={() => setOpen(true)}
              disabled={disabled}
              title={tooltip}>
        ✨ {t('products.batch_ai')}
      </button>
      {tooltip && <span style={{ marginLeft:10, fontSize:12, color:'var(--fg-muted)' }}>{tooltip}</span>}

      {open && (
        <Modal title={t('products.batch_ai')} onClose={() => { if (!busy) setOpen(false); }}>
          <div style={{ padding: 16, minWidth: 540, maxWidth: 720 }}>
            <div style={{ fontSize: 13, color:'var(--fg-muted)', marginBottom: 14 }}>
              {t('products.batch_ai_hint')}
            </div>

            {/* Reference image picker */}
            <div style={{ marginBottom: 14 }}>
              <strong style={{ fontSize: 13 }}>{t('products.batch_ref_label')}</strong>

              {refs.length > 0 && (
                <div style={{ marginTop: 6 }}>
                  <div style={{ fontSize: 11, color:'var(--fg-muted)', marginBottom: 4 }}>{t('products.batch_ref_real')}</div>
                  <div style={{ display:'flex', flexWrap:'wrap', gap:8 }}>
                    {refs.map((u, i) => {
                      const on = chosenRef === u;
                      return (
                        <label key={'r-'+i} style={{
                          position:'relative', cursor:'pointer',
                          width: 80, height: 80, borderRadius: 4,
                          border: on ? '2px solid var(--accent, #0ea5e9)' : '1px solid var(--border)',
                          background: '#fff', overflow:'hidden',
                        }}>
                          <input type="radio" name="batchRef" checked={on}
                                 onChange={() => setChosenRef(u)}
                                 style={{ position:'absolute', top:4, left:4, zIndex:2 }}/>
                          <img src={u} alt="" style={{ width:'100%', height:'100%', objectFit:'cover' }}/>
                        </label>
                      );
                    })}
                  </div>
                </div>
              )}

              {colorPool.length > 0 && (
                <div style={{ marginTop: 10 }}>
                  <div style={{ fontSize: 11, color:'var(--fg-muted)', marginBottom: 4 }}>{t('products.batch_ref_color')}</div>
                  <div style={{ display:'flex', flexWrap:'wrap', gap:8 }}>
                    {colorPool.map((x, i) => {
                      const on = chosenRef === x.url;
                      return (
                        <label key={'p-'+i} title={x.label} style={{
                          position:'relative', cursor:'pointer',
                          width: 64, height: 64, borderRadius: 4,
                          border: on ? '2px solid var(--accent, #0ea5e9)' : '1px solid var(--border)',
                          background: '#fff', overflow:'hidden',
                        }}>
                          <input type="radio" name="batchRef" checked={on}
                                 onChange={() => setChosenRef(x.url)}
                                 style={{ position:'absolute', top:3, left:3, zIndex:2 }}/>
                          <img src={x.url} alt="" style={{ width:'100%', height:'100%', objectFit:'cover' }}/>
                        </label>
                      );
                    })}
                  </div>
                </div>
              )}
            </div>

            {/* Color selection */}
            <div style={{ marginBottom: 14 }}>
              <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom: 6 }}>
                <strong style={{ fontSize: 13 }}>{t('products.batch_select_colors')}</strong>
                <span>
                  <button type="button" className="btn sm" onClick={pickAll}>{t('products.batch_select_all')}</button>
                  <button type="button" className="btn sm" onClick={pickNone} style={{ marginLeft:4 }}>×</button>
                </span>
              </div>
              <div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(140px, 1fr))', gap:8 }}>
                {colors.map((c, i) => {
                  const cn = (c?.name_i18n && (c.name_i18n.zh || c.name_i18n.en || Object.values(c.name_i18n)[0])) || c?.hex || ('#' + (i+1));
                  const on = picked.includes(i);
                  return (
                    <label key={i} style={{
                      display:'flex', alignItems:'center', gap:8, padding:'6px 10px',
                      border:'1px solid var(--border)', borderRadius:4, cursor:'pointer',
                      background: on ? '#f7f3ea' : '#fff',
                    }}>
                      <input type="checkbox" checked={on} onChange={() => togglePick(i)}/>
                      <span style={{
                        display:'inline-block', width:14, height:14, borderRadius:'50%',
                        background: c?.hex || '#888', border:'1px solid var(--border-strong)'
                      }}/>
                      <span style={{ fontSize:12 }}>{cn}</span>
                    </label>
                  );
                })}
              </div>
            </div>

            {/* Step 1 — counts */}
            <div style={{ marginBottom: 14, display:'flex', gap: 24, flexWrap:'wrap' }}>
              <div>
                <strong style={{ fontSize: 13 }}>{t('products.batch_images_per')}</strong>
                <div style={{ marginTop: 6 }}>
                  <input type="number" min={1} max={8} value={perColor}
                         onChange={e => setPerColor(Math.max(1, Math.min(8, Number(e.target.value) || 1)))}
                         style={{ width: 80 }}/>
                  <span style={{ marginLeft: 8, fontSize: 12, color:'var(--fg-muted)' }}>
                    {t('products.batch_shot_product')} {productCount} + {t('products.batch_shot_model')} {Mraw}
                  </span>
                </div>
              </div>
              <div>
                <strong style={{ fontSize: 13 }}>{t('products.batch_model_count')}</strong>
                <div style={{ marginTop: 6 }}>
                  <input type="number" min={0} max={perColor} value={modelCount}
                         onChange={e => setModelCount(Math.max(0, Math.min(perColor, Number(e.target.value) || 0)))}
                         style={{ width: 60 }}/>
                  <span style={{ marginLeft: 8, fontSize: 12, color:'var(--fg-muted)' }}>
                    / {perColor}
                  </span>
                </div>
              </div>
            </div>

            {/* Step 2 — generate prompts + edit */}
            <div style={{ marginBottom: 14 }}>
              <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom: 6 }}>
                <strong style={{ fontSize: 13 }}>{t('products.batch_full_prompts')}</strong>
                <button type="button" className="btn primary"
                        onClick={generatePromptsViaAI}
                        disabled={genBusy || !productId || !chosenRef}>
                  {genBusy ? t('products.batch_prompts_loading') : t('products.batch_gen_prompts')}
                </button>
              </div>
              <div style={{ fontSize: 11, color:'var(--fg-muted)', marginBottom: 6 }}>
                {t('products.batch_full_prompts_hint')}
              </div>
              <div style={{ display:'grid', gap: 8 }}>
                {Array.from({ length: N }).map((_, i) => {
                  const isModel = i >= productCount;
                  return (
                    <div key={i} style={{ display:'grid', gridTemplateColumns:'auto 1fr', gap:8, alignItems:'start' }}>
                      <div style={{
                        fontSize: 11, color:'var(--fg-muted)', minWidth: 88,
                        padding: '6px 0', whiteSpace:'nowrap',
                      }}>
                        {t('products.batch_shot')}{i+1}{t('products.batch_shot_unit')}
                        <span style={{ color: isModel ? '#a16207' : 'var(--fg-muted)', marginLeft: 4 }}>
                          {isModel ? t('products.batch_shot_model') : t('products.batch_shot_product')}
                        </span>
                      </div>
                      <textarea
                        value={shotPrompts[i] || ''}
                        onChange={e => {
                          const next = shotPrompts.slice();
                          while (next.length <= i) next.push('');
                          next[i] = e.target.value;
                          setShotPrompts(next);
                        }}
                        placeholder={genBusy ? t('products.batch_prompts_loading') : ''}
                        rows={3}
                        style={{
                          width:'100%', fontSize: 12, lineHeight: 1.5, padding: 6,
                          border:'1px solid var(--border)', borderRadius: 4,
                          fontFamily: 'inherit',
                        }}/>
                    </div>
                  );
                })}
              </div>
            </div>

            {job && (
              <div style={{
                margin: '10px 0', padding: '10px 12px', background: '#0b1220', color: '#cbd5e1',
                borderRadius: 4, fontSize: 12, fontFamily:'ui-monospace, monospace',
              }}>
                <div style={{ marginBottom: 6 }}>
                  {t('products.batch_progress')}: <strong>{job.done} / {job.total}</strong> ({pct}%)
                  {job.complete ? ' · ' + t('products.batch_done') : ''}
                  {job.errors?.length ? ' · ' + job.errors.length + ' ' + t('products.batch_failed') : ''}
                </div>
                <div style={{ height: 8, background: '#1e293b', borderRadius: 4, overflow: 'hidden' }}>
                  <div style={{
                    width: pct + '%', height: '100%',
                    background: job.complete ? '#16a34a' : '#0ea5e9',
                    transition: 'width 0.4s ease',
                  }}/>
                </div>
                {job.errors?.length > 0 && (
                  <div style={{ marginTop:6, color:'#fca5a5', maxHeight:120, overflow:'auto' }}>
                    {job.errors.slice(-5).map((e, idx) => (
                      <div key={idx}>· color #{(e.colorIdx ?? '?')+1} shot {e.shot ?? '?'}: {e.err}</div>
                    ))}
                  </div>
                )}
              </div>
            )}

            <div style={{ display:'flex', justifyContent:'flex-end', gap: 8 }}>
              {!job?.complete && (
                <button type="button" className="btn primary"
                        onClick={start}
                        disabled={busy || picked.length === 0 || !chosenRef
                                  || shotPrompts.length !== N
                                  || shotPrompts.some(s => !s || !String(s).trim())}>
                  {busy ? '…' : t('products.batch_run')}
                </button>
              )}
            </div>
          </div>
        </Modal>
      )}
    </div>
  );
}

function ProductEditor({ product, creating, cats, allTags, channels, onClose, onSaved, onError, toast }) {
  const t = useT();
  const { lang } = useLang();
  const [form, setForm] = useState(() => ({
    id:               product?.id || '',
    category_code:    product?.category_code || '',
    subcategory:      product?.subcategory || '',
    name_i18n:        product?.name_i18n || {},
    description_i18n: product?.description_i18n || {},
    material_i18n:    product?.material_i18n || {},
    fit_i18n:         product?.fit_i18n || {},
    care_i18n:        product?.care_i18n || {},
    price:            product ? Number(product.price) : 0,
    was_price:        product?.was_price == null ? '' : Number(product.was_price),
    color:            product?.color || '#0f0f0f',
    img_style:        product?.img_style || 'shirt',
    tag:              product?.tag || '',
    stock:            product?.stock ?? 99,
    active:           product?.active ?? true,
    featured:         product?.featured ?? false,
    sort_order:       product?.sort_order ?? 99,
    channels:         Array.isArray(product?.channels) ? product.channels : [],
    image_url:        product?.image_url || null,
    gallery_urls:     Array.isArray(product?.gallery_urls)
                        ? product.gallery_urls
                        : (Array.isArray(product?.gallery) ? product.gallery : []),
    tag_codes:        Array.isArray(product?.tag_codes) ? product.tag_codes : [],
    colors:           Array.isArray(product?.colors)
                        ? product.colors.map(c => (typeof c === 'string' ? { hex: c } : (c || {})))
                        : [],
    reference_urls:   Array.isArray(product?.reference_urls) ? product.reference_urls : [],
  }));
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
  const [busy, setBusy] = useState(false);

  // Cascading category picker — derive top-level and subs by parent_code.
  const tree = useMemo(() => buildCategoryTree(cats || []), [cats]);
  const byCode = useMemo(() => {
    const m = new Map(); (cats || []).forEach(c => m.set(c.code, c)); return m;
  }, [cats]);
  const currentNode = byCode.get(form.category_code);
  const currentTop = (() => {
    let n = currentNode;
    while (n && n.parent_code) n = byCode.get(n.parent_code);
    return n || null;
  })();
  const topLevel = tree;
  const subsOfTop = currentTop ? (tree.find(n => n.code === currentTop.code)?.children || []) : [];

  // Load existing tag_codes on open for existing products.
  useEffect(() => {
    if (!product?.id || creating) return;
    let live = true;
    (async () => {
      try {
        const codes = await YY_ADMIN.get('/products/' + product.id + '/tags');
        if (live && Array.isArray(codes)) setForm(f => ({ ...f, tag_codes: codes }));
      } catch (e) { /* non-fatal */ }
    })();
    return () => { live = false; };
  }, [product?.id, creating]);

  const toggleTagCode = (code) => {
    const have = form.tag_codes.includes(code);
    set('tag_codes', have ? form.tag_codes.filter(c => c !== code) : [...form.tag_codes, code]);
  };

  async function save() {
    if (!form.id) return onError(t('products.id_required'));
    setBusy(true);
    try {
      // Strip cache-bust query strings (?v=…) from URLs before persisting.
      const stripBust = (u) => (u ? String(u).replace(/[?&]v=\d+$/, '') : u);
      const body = {
        ...form,
        subcategory:  form.subcategory ? String(form.subcategory).trim() : null,
        was_price:    form.was_price === '' ? null : Number(form.was_price),
        price:        Number(form.price),
        stock:        Number(form.stock),
        sort_order:   Number(form.sort_order),
        channels:     Array.isArray(form.channels) ? form.channels : [],
        image_url:    form.image_url ? stripBust(form.image_url) : null,
        gallery_urls: Array.isArray(form.gallery_urls)
                        ? form.gallery_urls.map(u => u ? stripBust(u) : null)
                        : [],
        colors:       Array.isArray(form.colors)
                        ? form.colors
                            .filter(c => c && typeof c === 'object')
                            .map(c => ({
                              hex:          c.hex || '#cccccc',
                              name_i18n:    c.name_i18n || {},
                              image_url:    c.image_url ? stripBust(c.image_url) : null,
                              gallery_urls: Array.isArray(c.gallery_urls)
                                              ? c.gallery_urls.map(u => u ? stripBust(u) : null)
                                              : [],
                            }))
                        : [],
        reference_urls: Array.isArray(form.reference_urls)
                          ? form.reference_urls.filter(Boolean).map(stripBust)
                          : [],
      };
      // product-tags endpoint is separate — remove it from the body.
      delete body.tag_codes;
      if (creating) await YY_ADMIN.post('/products', body);
      else          await YY_ADMIN.put('/products/' + product.id, body);
      // After product saved, replace its tag set via dedicated endpoint.
      try { await YY_ADMIN.put('/products/' + form.id + '/tags', { tag_codes: form.tag_codes }); }
      catch (e) { /* surface but don't block */ onError(e.message); }
      onSaved();
    } catch (e) { onError(e.message); }
    finally { setBusy(false); }
  }

  const sectionHeader = (txt) => (
    <div style={{
      gridColumn: '1 / -1',
      borderTop: '1px solid var(--border)',
      paddingTop: 14,
      marginTop: 4,
      fontSize: 12,
      letterSpacing: '0.12em',
      textTransform: 'uppercase',
      color: 'var(--fg-muted)',
    }}>{txt}</div>
  );

  const pdpHref = product ? storefrontPdpUrl(product, lang) : null;

  return (
    <Modal title={creating ? t('products.new') : t('common.edit') + ' · ' + product.id} onClose={onClose} onSave={save} disabled={busy}>
      <div className="form-grid">
        {sectionHeader(t('products.basic_section'))}

        <Field label={t('products.field_id')}>
          <input value={form.id} disabled={!creating} onChange={e=>set('id', e.target.value)} placeholder="e.g. p17"/>
        </Field>
        <Field label={t('products.field_category')}>
          <div style={{ display: 'flex', gap: 6 }}>
            <select
              value={currentTop?.code || ''}
              onChange={e => {
                const top = e.target.value;
                // When switching top-level, clear the leaf until user picks a sub (or just use the top).
                set('category_code', top);
              }}
              style={{ flex: 1 }}
            >
              <option value="">—</option>
              {topLevel.map(c => (
                <option key={c.code} value={c.code}>{pickI18n(c.name_i18n, lang) || c.code}</option>
              ))}
            </select>
            <select
              value={(currentNode && currentNode.parent_code === currentTop?.code) ? currentNode.code : (currentTop && form.category_code === currentTop.code ? '' : form.category_code || '')}
              onChange={e => set('category_code', e.target.value || currentTop?.code || '')}
              disabled={subsOfTop.length === 0}
              style={{ flex: 1 }}
              title={subsOfTop.length === 0 ? '(no subcategories)' : ''}
            >
              <option value="">{subsOfTop.length === 0 ? '—' : '(pick sub…)'}</option>
              {subsOfTop.map(c => (
                <option key={c.code} value={c.code}>{pickI18n(c.name_i18n, lang) || c.code}</option>
              ))}
            </select>
          </div>
        </Field>

        <Field label={t('products.field_subcat')}>
          <input value={form.subcategory} onChange={e=>set('subcategory', e.target.value)} placeholder="(legacy free-text, optional)"/>
        </Field>
        <Field label={t('products.field_style')}>
          <select value={form.img_style} onChange={e=>set('img_style', e.target.value)}>
            {IMG_STYLES.map(s => <option key={s} value={s}>{s}</option>)}
          </select>
        </Field>

        <Field label={t('products.field_price')}><input type="number" step="0.01" value={form.price} onChange={e=>set('price', e.target.value)}/></Field>
        <Field label={t('products.field_was')}><input type="number" step="0.01" value={form.was_price} onChange={e=>set('was_price', e.target.value)} placeholder="(optional)"/></Field>

        <Field label={t('products.field_color')}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
            <input value={form.color} onChange={e=>set('color', e.target.value)} placeholder="#0f0f0f" style={{ flex: 1, fontFamily: 'ui-monospace, monospace', fontSize: 12 }}/>
            <input type="color" value={form.color && /^#[0-9a-f]{6}$/i.test(form.color) ? form.color : '#cccccc'}
                   onChange={e=>set('color', e.target.value)}
                   style={{ width: 36, height: 28, padding: 0, border: '1px solid var(--border)', borderRadius: 3 }}/>
          </div>
        </Field>
        <Field label={t('products.field_tag')}><input value={form.tag || ''} onChange={e=>set('tag', e.target.value)} placeholder="e.g. Heritage"/></Field>

        <Field label={t('products.field_stock')}><input type="number" value={form.stock} onChange={e=>set('stock', e.target.value)}/></Field>
        <Field label={t('products.field_sort')}><input type="number" value={form.sort_order} onChange={e=>set('sort_order', e.target.value)}/></Field>

        <Field label={t('products.field_flags')}>
          <div style={{ display: 'flex', gap: 14, alignItems: 'center', paddingTop: 6 }}>
            <label><input type="checkbox" checked={!!form.active} onChange={e=>set('active', e.target.checked)}/> {t('products.field_active')}</label>
            <label><input type="checkbox" checked={!!form.featured} onChange={e=>set('featured', e.target.checked)}/> {t('products.field_featured')}</label>
          </div>
        </Field>
        <Field label=" ">
          {pdpHref ? (
            <a className="btn sm" href={pdpHref} target="_blank" rel="noopener"
               style={{ textDecoration: 'none', display: 'inline-flex', alignItems: 'center', marginTop: 6 }}>
              {t('products.view_pdp')} ↗
            </a>
          ) : <span>&nbsp;</span>}
        </Field>

        <Field label={t('products.field_channels')} full>
          <div style={{ fontSize: 12, color: 'var(--fg-muted)', marginBottom: 6 }}>{t('products.field_channels_help')}</div>
          <ChannelsPicker channels={channels} value={form.channels} onChange={v => set('channels', v)}/>
        </Field>

        <Field label={t('products.field_tags')} full>
          <div style={{ fontSize: 12, color: 'var(--fg-muted)', marginBottom: 6 }}>{t('products.field_tags_help')}</div>
          {(!allTags || allTags.length === 0) ? (
            <div style={{ fontSize: 12, color: 'var(--fg-subtle)' }}>{t('products.no_tags')}</div>
          ) : (
            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
              {allTags.map(tg => {
                const on = form.tag_codes.includes(tg.code);
                return (
                  <label key={tg.code} className={'pill ' + (on ? 'ok' : '')} style={{ cursor: 'pointer', userSelect: 'none' }}>
                    <input type="checkbox" checked={on} onChange={() => toggleTagCode(tg.code)} style={{ marginRight: 4 }}/>
                    {pickI18n(tg.name_i18n, lang) || tg.code}
                  </label>
                );
              })}
            </div>
          )}
        </Field>

        <Field label={t('products.field_name')} full><I18nField value={form.name_i18n} onChange={v=>set('name_i18n', v)}/></Field>

        {sectionHeader(t('products.references_section'))}

        <Field label=" " full>
          <ReferenceUploader
            productId={product?.id || form.id}
            value={form.reference_urls}
            onChange={(next) => set('reference_urls', next)}
            onError={onError}
            toast={toast}
          />
          <BatchAIButton
            productId={product?.id || form.id}
            productForm={form}
            onCompleted={(updatedColors) => {
              if (Array.isArray(updatedColors)) set('colors', updatedColors);
            }}
            onError={onError}
            toast={toast}
          />
        </Field>

        {sectionHeader(t('products.colors_section'))}

        <Field label=" " full>
          <ColorVariantsEditor
            productId={product?.id || form.id}
            productForm={form}
            value={form.colors}
            onChange={(next) => set('colors', next)}
            onError={onError}
            toast={toast}
          />
        </Field>

        {sectionHeader(t('products.detail_section'))}

        <Field label={t('products.field_desc')} full><I18nField value={form.description_i18n} onChange={v=>set('description_i18n', v)} textarea/></Field>
        <Field label={t('products.field_material')} full><I18nField value={form.material_i18n} onChange={v=>set('material_i18n', v)} textarea/></Field>
        <Field label={t('products.field_fit')} full><I18nField value={form.fit_i18n} onChange={v=>set('fit_i18n', v)} textarea/></Field>
        <Field label={t('products.field_care')} full><I18nField value={form.care_i18n} onChange={v=>set('care_i18n', v)} textarea/></Field>
      </div>
    </Modal>
  );
}

// ============================ orders ========================================
function OrdersPage({ toast }) {
  const t = useT();
  const { lang } = useLang();
  const [rows, setRows] = useState(null);
  const [detail, setDetail] = useState(null);
  const [filter, setFilter] = useState('');

  const reload = async () => setRows(await YY_ADMIN.get('/orders?limit=500' + (filter ? '&status=' + filter : '')));
  useEffect(() => { reload(); }, [filter]);

  async function updateStatus(id, status) {
    try { await YY_ADMIN.put('/orders/' + id, { status }); toast(t('status.' + status) || status); reload(); } catch (e) { toast(e.message, true); }
  }
  async function remove(id) {
    if (!confirm(t('orders.delete_confirm', { id }))) return;
    try { await YY_ADMIN.del('/orders/' + id); toast(t('common.deleted')); reload(); } catch (e) { toast(e.message, true); }
  }

  return (
    <>
      <div className="page-head">
        <div><h1>{t('orders.title')}</h1><div className="sub">{rows ? `${rows.length} ${t('orders.count')}` : '…'}</div></div>
        <div className="actions">
          <select value={filter} onChange={e=>setFilter(e.target.value)} style={{ padding: '8px 12px', border: '1px solid var(--border-strong)', borderRadius: 4, background: 'var(--bg-alt)' }}>
            <option value="">{t('orders.all_statuses')}</option>
            <option value="pending">{t('status.pending')}</option>
            <option value="paid">{t('status.paid')}</option>
            <option value="shipped">{t('status.shipped')}</option>
            <option value="delivered">{t('status.delivered')}</option>
            <option value="cancelled">{t('status.cancelled')}</option>
          </select>
        </div>
      </div>

      <div className="panel">
        <div className="panel-body">
          {rows === null ? <div className="loading">{t('common.loading')}</div> :
           rows.length === 0 ? <div className="empty">{t('common.empty')}</div> :
           <table className="t">
             <thead><tr>
               <th>{t('orders.col_order')}</th><th>{t('orders.col_customer')}</th>
               <th>{t('orders.col_channel')}</th><th>{t('orders.col_items')}</th>
               <th>{t('orders.col_total')}</th><th>{t('common.state')}</th>
               <th>{t('orders.col_placed')}</th><th></th>
             </tr></thead>
             <tbody>
               {rows.map(o => (
                 <tr key={o.id}>
                   <td className="mono">{o.order_no}</td>
                   <td>
                     <div>{o.customer_name || '—'}</div>
                     <div style={{ color: 'var(--fg-muted)', fontSize: 12 }}>{o.email}</div>
                   </td>
                   <td>{o.channel_id || '—'} · {o.lang}</td>
                   <td>{(o.items || []).length}</td>
                   <td>${Number(o.total).toFixed(2)} {o.currency}</td>
                   <td><span className={'pill ' + (o.status === 'paid' || o.status === 'shipped' || o.status === 'delivered' ? 'ok' : o.status === 'cancelled' ? 'off' : 'warn')}>{t('status.' + o.status) || o.status}</span></td>
                   <td>{new Date(o.created_at).toLocaleString(lang === 'zh' ? 'zh-CN' : 'en-US')}</td>
                   <td>
                     <div className="row-actions">
                       <button onClick={() => setDetail(o)}>{t('common.view')}</button>
                       {o.status !== 'shipped'   && <button onClick={() => updateStatus(o.id, 'shipped')}>{t('common.ship')}</button>}
                       {o.status !== 'cancelled' && <button className="danger" onClick={() => updateStatus(o.id, 'cancelled')}>{t('common.cancel_order')}</button>}
                       <button className="danger" onClick={() => remove(o.id)}>{t('common.delete')}</button>
                     </div>
                   </td>
                 </tr>
               ))}
             </tbody>
           </table>}
        </div>
      </div>

      {detail && (
        <Modal title={t('orders.detail_title', { no: detail.order_no })} onClose={() => setDetail(null)}>
          <div className="form-grid">
            <Field label={t('orders.f_customer')}><input value={detail.customer_name || ''} readOnly/></Field>
            <Field label={t('orders.f_email')}><input value={detail.email} readOnly/></Field>
            <Field label={t('orders.f_phone')}><input value={detail.phone || ''} readOnly/></Field>
            <Field label={t('orders.f_payment')}><input value={detail.payment_method || ''} readOnly/></Field>
            <Field label={t('orders.f_address')} full>
              <textarea readOnly value={[detail.address1, detail.address2, detail.city, detail.postal, detail.country].filter(Boolean).join(', ')}/>
            </Field>
          </div>
          <h4 style={{ marginTop: 16, marginBottom: 8 }}>{t('orders.items_h')}</h4>
          <table className="t">
            <thead><tr><th>{t('orders.col_ideprod')}</th><th>{t('common.name')}</th><th>Qty</th><th>Unit</th><th>Line</th></tr></thead>
            <tbody>
              {(detail.items || []).map((it, i) => (
                <tr key={i}>
                  <td className="mono">{it.id}</td>
                  <td>{pickI18n(it.name, lang) || '—'}</td>
                  <td>{it.qty || 1}</td>
                  <td>${Number(it.price).toFixed(2)}</td>
                  <td>${(Number(it.price) * (it.qty || 1)).toFixed(2)}</td>
                </tr>
              ))}
            </tbody>
          </table>
          <div style={{ marginTop: 16, textAlign: 'right', fontSize: 14 }}>
            <div>{t('orders.subtotal')} ${Number(detail.subtotal).toFixed(2)}</div>
            <div>{t('orders.shipping')} ${Number(detail.shipping).toFixed(2)}</div>
            <div>{t('orders.tax')} ${Number(detail.tax).toFixed(2)}</div>
            <div style={{ fontFamily: 'var(--font-display)', fontSize: 20, marginTop: 8 }}>{t('orders.total')} ${Number(detail.total).toFixed(2)} {detail.currency}</div>
          </div>
        </Modal>
      )}
    </>
  );
}

// ============================ channels ======================================
function ChannelsPage({ toast }) {
  const t = useT();
  const { lang } = useLang();
  const [rows, setRows] = useState(null);
  const [themes, setThemes] = useState([]);
  const [edit, setEdit] = useState(null);
  const [creating, setCreating] = useState(false);

  const reload = async () => {
    const [c, th] = await Promise.all([YY_ADMIN.get('/channels'), YY_ADMIN.get('/themes')]);
    setRows(c); setThemes(th);
  };
  useEffect(() => { reload(); }, []);

  async function remove(id) {
    if (!confirm(t('channels.delete_confirm', { id }))) return;
    try { await YY_ADMIN.del('/channels/' + id); toast(t('common.deleted')); reload(); } catch (e) { toast(e.message, true); }
  }

  return (
    <>
      <div className="page-head">
        <div><h1>{t('channels.title')}</h1><div className="sub">{rows ? `${rows.length} ${t('channels.sub')}` : '…'}</div></div>
        <div className="actions"><button className="btn primary" onClick={() => setCreating(true)}>{t('channels.new')}</button></div>
      </div>

      <div className="panel">
        <div className="panel-body">
          {rows === null ? <div className="loading">{t('common.loading')}</div> :
           <table className="t">
             <thead><tr>
               <th>{t('common.id')}</th>
               <th>{t('common.name')} (EN)</th>
               <th>{t('common.name')} (ZH)</th>
               <th>{t('channels.col_theme')}</th>
               <th>{t('channels.col_flag')}</th>
               <th>{t('common.sort_order')}</th>
               <th>{t('common.state')}</th>
               <th></th>
             </tr></thead>
             <tbody>
               {rows.map(c => (
                 <tr key={c.id}>
                   <td className="mono">{c.id}</td>
                   <td>{c.name_i18n?.en}</td>
                   <td>{c.name_i18n?.zh}</td>
                   <td>{c.theme_code || '—'}</td>
                   <td>{c.flag}</td>
                   <td>{c.sort_order}</td>
                   <td><span className={'pill ' + (c.active ? 'ok' : 'off')}>{c.active ? t('common.active') : t('common.off')}</span></td>
                   <td>
                     <div className="row-actions">
                       <button onClick={() => setEdit(c)}>{t('common.edit')}</button>
                       <button className="danger" onClick={() => remove(c.id)}>{t('common.delete')}</button>
                     </div>
                   </td>
                 </tr>
               ))}
             </tbody>
           </table>}
        </div>
      </div>

      {(edit || creating) && (
        <ChannelEditor
          channel={edit} creating={creating} themes={themes}
          onClose={() => { setEdit(null); setCreating(false); }}
          onSaved={async () => { setEdit(null); setCreating(false); await reload(); toast(t('common.saved')); }}
          onError={(m) => toast(m, true)}
        />
      )}
    </>
  );
}

function ChannelEditor({ channel, creating, themes, onClose, onSaved, onError }) {
  const t = useT();
  const { lang } = useLang();
  const [form, setForm] = useState(() => ({
    id: channel?.id || '',
    theme_code: channel?.theme_code || 'modern',
    flag: channel?.flag || '',
    name_i18n: channel?.name_i18n || {},
    description_i18n: channel?.description_i18n || {},
    sort_order: channel?.sort_order ?? 99,
    active: channel?.active ?? true,
  }));
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
  const [busy, setBusy] = useState(false);

  async function save() {
    if (!form.id) return onError(t('products.id_required'));
    setBusy(true);
    try {
      const body = { ...form, sort_order: Number(form.sort_order) };
      if (creating) await YY_ADMIN.post('/channels', body);
      else          await YY_ADMIN.put('/channels/' + channel.id, body);
      onSaved();
    } catch (e) { onError(e.message); }
    finally { setBusy(false); }
  }

  return (
    <Modal title={creating ? t('channels.new') : t('common.edit') + ' · ' + channel.id} onClose={onClose} onSave={save} disabled={busy}>
      <div className="form-grid">
        <Field label={t('channels.field_id')}><input disabled={!creating} value={form.id} onChange={e=>set('id', e.target.value)} placeholder="e.g. sea"/></Field>
        <Field label={t('channels.field_theme')}>
          <select value={form.theme_code} onChange={e=>set('theme_code', e.target.value)}>
            {themes.map(th => <option key={th.code} value={th.code}>{th.code} — {pickI18n(th.name_i18n, lang) || th.label}</option>)}
          </select>
        </Field>
        <Field label={t('channels.field_flag')}><input value={form.flag} onChange={e=>set('flag', e.target.value)} placeholder="🌐 or US"/></Field>
        <Field label={t('channels.field_sort')}><input type="number" value={form.sort_order} onChange={e=>set('sort_order', e.target.value)}/></Field>
        <Field label={t('banners.field_active')}>
          <div style={{ paddingTop: 6 }}><label><input type="checkbox" checked={!!form.active} onChange={e=>set('active', e.target.checked)}/> {t('common.live')}</label></div>
        </Field>
        <Field label={t('channels.field_name')} full><I18nField value={form.name_i18n} onChange={v=>set('name_i18n', v)}/></Field>
        <Field label={t('channels.field_desc')} full><I18nField value={form.description_i18n} onChange={v=>set('description_i18n', v)} textarea/></Field>
      </div>
    </Modal>
  );
}

// ============================ themes ========================================
function ThemesPage({ toast }) {
  const t = useT();
  const { lang } = useLang();
  const [rows, setRows] = useState(null);
  const [edit, setEdit] = useState(null);
  const reload = async () => setRows(await YY_ADMIN.get('/themes'));
  useEffect(() => { reload(); }, []);

  return (
    <>
      <div className="page-head">
        <div><h1>{t('themes.title')}</h1><div className="sub">{rows ? `${rows.length} ${t('themes.sub')}` : '…'}</div></div>
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16 }}>
        {(rows || []).map(th => (
          <div key={th.code} className="theme-card">
            <div>
              <div className="name">{pickI18n(th.name_i18n, lang) || th.code}</div>
              <div className="label-line">{th.label}</div>
            </div>
            <div className="palette">
              {(th.swatch || []).map((s, i) => <div key={i} className="sw" style={{ background: s }}/>)}
            </div>
            <div style={{ fontSize: 12, color: 'var(--fg-muted)' }}>{t('themes.accent')} <span className="swatch-dot" style={{ background: th.accent }}/> {th.accent}</div>
            <div><button className="btn sm" onClick={() => setEdit(th)}>{t('common.edit')}</button></div>
          </div>
        ))}
      </div>
      {edit && <ThemeEditor theme={edit} onClose={() => setEdit(null)} onSaved={async () => { setEdit(null); await reload(); toast(t('common.saved')); }} onError={(m) => toast(m, true)}/>}
    </>
  );
}

function ThemeEditor({ theme, onClose, onSaved, onError }) {
  const t = useT();
  const [form, setForm] = useState(() => ({
    name_i18n: theme.name_i18n || {},
    label: theme.label || '',
    swatch: (theme.swatch || []).join(', '),
    accent: theme.accent || '#000000',
    sort_order: theme.sort_order ?? 99,
  }));
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
  const [busy, setBusy] = useState(false);

  async function save() {
    setBusy(true);
    try {
      const swatch = form.swatch.split(',').map(s => s.trim()).filter(Boolean);
      await YY_ADMIN.put('/themes/' + theme.code, { ...form, swatch, sort_order: Number(form.sort_order) });
      onSaved();
    } catch (e) { onError(e.message); }
    finally { setBusy(false); }
  }

  return (
    <Modal title={t('common.edit') + ' · ' + theme.code} onClose={onClose} onSave={save} disabled={busy}>
      <div className="form-grid">
        <Field label={t('themes.field_label_zh')}><input value={form.label} onChange={e=>set('label', e.target.value)}/></Field>
        <Field label={t('themes.field_accent')}><input value={form.accent} onChange={e=>set('accent', e.target.value)}/></Field>
        <Field label={t('themes.field_swatch')} full><input value={form.swatch} onChange={e=>set('swatch', e.target.value)} placeholder="#0a0a0a, #ffffff, #e8e3dc"/></Field>
        <Field label={t('themes.field_sort')}><input type="number" value={form.sort_order} onChange={e=>set('sort_order', e.target.value)}/></Field>
        <Field label={t('themes.field_name')} full><I18nField value={form.name_i18n} onChange={v=>set('name_i18n', v)}/></Field>
      </div>
    </Modal>
  );
}

// ============================ banners =======================================
function BannersPage({ toast }) {
  const t = useT();
  const [rows, setRows] = useState(null);
  const [channels, setChannels] = useState([]);
  const [edit, setEdit] = useState(null);
  const [creating, setCreating] = useState(false);

  const reload = async () => {
    const [b, c] = await Promise.all([YY_ADMIN.get('/banners'), YY_ADMIN.get('/channels')]);
    setRows(b); setChannels(c);
  };
  useEffect(() => { reload(); }, []);

  async function remove(id) {
    if (!confirm(t('banners.delete_confirm', { id }))) return;
    try { await YY_ADMIN.del('/banners/' + id); toast(t('common.deleted')); reload(); } catch (e) { toast(e.message, true); }
  }

  return (
    <>
      <div className="page-head">
        <div><h1>{t('banners.title')}</h1><div className="sub">{t('banners.sub')}</div></div>
        <div className="actions"><button className="btn primary" onClick={() => setCreating(true)}>{t('banners.new')}</button></div>
      </div>
      <div className="panel"><div className="panel-body">
        {rows === null ? <div className="loading">{t('common.loading')}</div> :
         rows.length === 0 ? <div className="empty">{t('common.empty')}</div> :
         <table className="t">
           <thead><tr>
             <th>{t('common.id')}</th>
             <th>{t('orders.col_channel')}</th>
             <th>{t('banners.col_title_en')}</th>
             <th>{t('banners.col_cta_target')}</th>
             <th>{t('common.sort_order')}</th>
             <th>{t('common.state')}</th>
             <th></th>
           </tr></thead>
           <tbody>
             {rows.map(b => (
               <tr key={b.id}>
                 <td className="mono">{b.id}</td>
                 <td>{b.channel_id}</td>
                 <td>{(b.title_i18n?.en || '').split('\n')[0]}</td>
                 <td>{b.cta_target}</td>
                 <td>{b.sort_order}</td>
                 <td><span className={'pill ' + (b.active ? 'ok' : 'off')}>{b.active ? t('common.active') : t('common.off')}</span></td>
                 <td>
                   <div className="row-actions">
                     <button onClick={() => setEdit(b)}>{t('common.edit')}</button>
                     <button className="danger" onClick={() => remove(b.id)}>{t('common.delete')}</button>
                   </div>
                 </td>
               </tr>
             ))}
           </tbody>
         </table>}
      </div></div>

      {(edit || creating) && (
        <BannerEditor
          banner={edit} creating={creating} channels={channels}
          onClose={() => { setEdit(null); setCreating(false); }}
          onSaved={async () => { setEdit(null); setCreating(false); await reload(); toast(t('common.saved')); }}
          onError={(m) => toast(m, true)}
        />
      )}
    </>
  );
}

function BannerEditor({ banner, creating, channels, onClose, onSaved, onError }) {
  const t = useT();
  const { lang } = useLang();
  const [form, setForm] = useState(() => ({
    channel_id: banner?.channel_id || (channels[0]?.id || 'global'),
    title_i18n: banner?.title_i18n || {},
    subtitle_i18n: banner?.subtitle_i18n || {},
    tag_i18n: banner?.tag_i18n || {},
    cta_label_i18n: banner?.cta_label_i18n || {},
    cta_target: banner?.cta_target || 'category',
    sort_order: banner?.sort_order ?? 0,
    active: banner?.active ?? true,
  }));
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
  const [busy, setBusy] = useState(false);

  async function save() {
    setBusy(true);
    try {
      const body = { ...form, sort_order: Number(form.sort_order) };
      if (creating) await YY_ADMIN.post('/banners', body);
      else          await YY_ADMIN.put('/banners/' + banner.id, body);
      onSaved();
    } catch (e) { onError(e.message); }
    finally { setBusy(false); }
  }

  return (
    <Modal title={creating ? t('banners.new') : t('common.edit') + ' · #' + banner.id} onClose={onClose} onSave={save} disabled={busy}>
      <div className="form-grid">
        <Field label={t('banners.field_channel')}>
          <select value={form.channel_id} onChange={e=>set('channel_id', e.target.value)}>
            {channels.map(c => <option key={c.id} value={c.id}>{c.id} — {pickI18n(c.name_i18n, lang) || c.id}</option>)}
          </select>
        </Field>
        <Field label={t('banners.field_cta_target')}>
          <select value={form.cta_target} onChange={e=>set('cta_target', e.target.value)}>
            {['home','category','pdp','culture','checkout'].map(s => <option key={s} value={s}>{s}</option>)}
          </select>
        </Field>
        <Field label={t('banners.field_sort')}><input type="number" value={form.sort_order} onChange={e=>set('sort_order', e.target.value)}/></Field>
        <Field label={t('banners.field_active')}>
          <div style={{ paddingTop: 6 }}><label><input type="checkbox" checked={!!form.active} onChange={e=>set('active', e.target.checked)}/> {t('common.live')}</label></div>
        </Field>
        <Field label={t('banners.field_tag')} full><I18nField value={form.tag_i18n} onChange={v=>set('tag_i18n', v)}/></Field>
        <Field label={t('banners.field_title')} full><I18nField value={form.title_i18n} onChange={v=>set('title_i18n', v)} textarea/></Field>
        <Field label={t('banners.field_subtitle')} full><I18nField value={form.subtitle_i18n} onChange={v=>set('subtitle_i18n', v)} textarea/></Field>
        <Field label={t('banners.field_cta_label')} full><I18nField value={form.cta_label_i18n} onChange={v=>set('cta_label_i18n', v)}/></Field>
      </div>
    </Modal>
  );
}

// ============================ culture =======================================
function CulturePage({ toast }) {
  const t = useT();
  const [rows, setRows] = useState(null);
  const [edit, setEdit] = useState(null);
  const [creating, setCreating] = useState(false);
  const reload = async () => setRows(await YY_ADMIN.get('/culture'));
  useEffect(() => { reload(); }, []);

  async function remove(id) {
    if (!confirm(t('culture.delete_confirm'))) return;
    try { await YY_ADMIN.del('/culture/' + id); toast(t('common.deleted')); reload(); } catch (e) { toast(e.message, true); }
  }

  return (
    <>
      <div className="page-head">
        <div><h1>{t('culture.title')}</h1><div className="sub">{t('culture.sub')}</div></div>
        <div className="actions"><button className="btn primary" onClick={() => setCreating(true)}>{t('culture.new')}</button></div>
      </div>
      <div className="panel"><div className="panel-body">
        {rows === null ? <div className="loading">{t('common.loading')}</div> :
         <table className="t">
           <thead><tr>
             <th>{t('common.id')}</th>
             <th>{t('culture.col_mark')}</th>
             <th>{t('culture.col_title_en')}</th>
             <th>{t('culture.col_desc_en')}</th>
             <th>{t('common.color')}</th>
             <th>{t('common.sort_order')}</th>
             <th>{t('common.state')}</th>
             <th></th>
           </tr></thead>
           <tbody>
             {rows.map(c => (
               <tr key={c.id}>
                 <td className="mono">{c.id}</td>
                 <td style={{ fontFamily: 'var(--font-display)', fontSize: 22 }}>{c.mark}</td>
                 <td>{c.title_i18n?.en}</td>
                 <td style={{ color: 'var(--fg-muted)', fontSize: 12 }}>{c.desc_i18n?.en}</td>
                 <td><span className="color-pill"><span className="swatch-dot" style={{ background: c.color }}/>{c.color}</span></td>
                 <td>{c.sort_order}</td>
                 <td><span className={'pill ' + (c.active ? 'ok' : 'off')}>{c.active ? t('common.active') : t('common.off')}</span></td>
                 <td>
                   <div className="row-actions">
                     <button onClick={() => setEdit(c)}>{t('common.edit')}</button>
                     <button className="danger" onClick={() => remove(c.id)}>{t('common.delete')}</button>
                   </div>
                 </td>
               </tr>
             ))}
           </tbody>
         </table>}
      </div></div>
      {(edit || creating) && (
        <CultureEditor
          collection={edit} creating={creating}
          onClose={() => { setEdit(null); setCreating(false); }}
          onSaved={async () => { setEdit(null); setCreating(false); await reload(); toast(t('common.saved')); }}
          onError={(m) => toast(m, true)}
        />
      )}
    </>
  );
}

function CultureEditor({ collection, creating, onClose, onSaved, onError }) {
  const t = useT();
  const [form, setForm] = useState(() => ({
    mark: collection?.mark || '',
    title_i18n: collection?.title_i18n || {},
    desc_i18n: collection?.desc_i18n || {},
    color: collection?.color || '#8b0000',
    sort_order: collection?.sort_order ?? 99,
    active: collection?.active ?? true,
  }));
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
  const [busy, setBusy] = useState(false);

  async function save() {
    setBusy(true);
    try {
      const body = { ...form, sort_order: Number(form.sort_order) };
      if (creating) await YY_ADMIN.post('/culture', body);
      else          await YY_ADMIN.put('/culture/' + collection.id, body);
      onSaved();
    } catch (e) { onError(e.message); }
    finally { setBusy(false); }
  }

  return (
    <Modal title={creating ? t('culture.new') : t('common.edit') + ' · #' + collection.id} onClose={onClose} onSave={save} disabled={busy}>
      <div className="form-grid">
        <Field label={t('culture.field_mark')}><input value={form.mark} onChange={e=>set('mark', e.target.value)} maxLength={2}/></Field>
        <Field label={t('culture.field_color')}><input value={form.color} onChange={e=>set('color', e.target.value)}/></Field>
        <Field label={t('culture.field_sort')}><input type="number" value={form.sort_order} onChange={e=>set('sort_order', e.target.value)}/></Field>
        <Field label={t('culture.field_active')}><div style={{ paddingTop: 6 }}><label><input type="checkbox" checked={!!form.active} onChange={e=>set('active', e.target.checked)}/> {t('common.live')}</label></div></Field>
        <Field label={t('culture.field_title')} full><I18nField value={form.title_i18n} onChange={v=>set('title_i18n', v)}/></Field>
        <Field label={t('culture.field_desc')} full><I18nField value={form.desc_i18n} onChange={v=>set('desc_i18n', v)} textarea/></Field>
      </div>
    </Modal>
  );
}

// ============================ i18n strings ==================================
function I18nPage({ toast }) {
  const t = useT();
  const [rows, setRows] = useState(null);
  const [edit, setEdit] = useState(null);
  const [creating, setCreating] = useState(false);
  const [q, setQ] = useState('');

  const reload = async () => setRows(await YY_ADMIN.get('/i18n'));
  useEffect(() => { reload(); }, []);

  async function remove(key) {
    if (!confirm(t('i18nkeys.delete_confirm', { key }))) return;
    try { await YY_ADMIN.del('/i18n/' + encodeURIComponent(key)); toast(t('common.deleted')); reload(); } catch (e) { toast(e.message, true); }
  }

  const shown = (rows || []).filter(r => !q || r.key.toLowerCase().includes(q.toLowerCase()));

  return (
    <>
      <div className="page-head">
        <div><h1>{t('i18nkeys.title')}</h1><div className="sub">{rows ? `${rows.length} ${t('i18nkeys.count', { n: LANG_ORDER.length })}` : '…'}</div></div>
        <div className="actions">
          <input placeholder={t('i18nkeys.filter')} value={q} onChange={e=>setQ(e.target.value)} style={{ padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 4 }}/>
          <button className="btn primary" onClick={() => setCreating(true)}>{t('i18nkeys.new')}</button>
        </div>
      </div>
      <div className="panel"><div className="panel-body">
        {rows === null ? <div className="loading">{t('common.loading')}</div> :
         <table className="t">
           <thead><tr><th>{t('i18nkeys.col_key')}</th><th>EN</th><th>ZH</th><th>…</th><th></th></tr></thead>
           <tbody>
             {shown.map(r => (
               <tr key={r.key}>
                 <td className="mono">{r.key}</td>
                 <td>{r.values.en}</td>
                 <td>{r.values.zh}</td>
                 <td style={{ color: 'var(--fg-muted)', fontSize: 12 }}>
                   {t('i18nkeys.others', {
                     n: LANG_ORDER.filter(l => l!=='en' && l!=='zh' && r.values[l]).length,
                     total: LANG_ORDER.length - 2,
                   })}
                 </td>
                 <td>
                   <div className="row-actions">
                     <button onClick={() => setEdit(r)}>{t('common.edit')}</button>
                     <button className="danger" onClick={() => remove(r.key)}>{t('common.delete')}</button>
                   </div>
                 </td>
               </tr>
             ))}
           </tbody>
         </table>}
      </div></div>
      {(edit || creating) && (
        <I18nEditor
          row={edit} creating={creating}
          onClose={() => { setEdit(null); setCreating(false); }}
          onSaved={async () => { setEdit(null); setCreating(false); await reload(); toast(t('common.saved')); }}
          onError={(m) => toast(m, true)}
        />
      )}
    </>
  );
}

function I18nEditor({ row, creating, onClose, onSaved, onError }) {
  const t = useT();
  const [key, setKey] = useState(row?.key || '');
  const [values, setValues] = useState(row?.values || {});
  const [busy, setBusy] = useState(false);

  async function save() {
    if (!key) return onError(t('i18nkeys.key_required'));
    setBusy(true);
    try {
      await YY_ADMIN.put('/i18n/' + encodeURIComponent(key), { values });
      onSaved();
    } catch (e) { onError(e.message); }
    finally { setBusy(false); }
  }

  return (
    <Modal title={creating ? t('i18nkeys.new') : t('common.edit') + ' · ' + row.key} onClose={onClose} onSave={save} disabled={busy}>
      <Field label={t('i18nkeys.field_key')}>
        <input disabled={!creating} value={key} onChange={e=>setKey(e.target.value)}/>
      </Field>
      <div style={{ height: 12 }}/>
      <Field label={t('i18nkeys.field_values')} full>
        <I18nField value={values} onChange={setValues} textarea/>
      </Field>
    </Modal>
  );
}

// ============================ categories ====================================
// Build a tree of categories from the flat list by parent_code.
function buildCategoryTree(rows) {
  const byCode = new Map(rows.map(r => [r.code, { ...r, children: [] }]));
  const roots = [];
  for (const node of byCode.values()) {
    if (node.parent_code && byCode.has(node.parent_code)) {
      byCode.get(node.parent_code).children.push(node);
    } else {
      roots.push(node);
    }
  }
  const sortRec = (list) => {
    list.sort((a, b) => (a.sort_order - b.sort_order) || a.code.localeCompare(b.code));
    list.forEach(n => sortRec(n.children));
  };
  sortRec(roots);
  return roots;
}

// Gather a node's full descendant set (self not included).
function descendantsOf(node) {
  const out = [];
  const walk = (n) => { for (const c of n.children) { out.push(c); walk(c); } };
  walk(node);
  return out;
}

// Flat list for a "parent picker" select — depth-prefixed, with disabled
// options for the node itself and its descendants (to prevent cycles).
function flattenForParentPicker(roots, selfCode) {
  const blocked = new Set();
  const findSelf = (list) => {
    for (const n of list) {
      if (n.code === selfCode) { blocked.add(n.code); descendantsOf(n).forEach(d => blocked.add(d.code)); return true; }
      if (findSelf(n.children)) return true;
    }
    return false;
  };
  findSelf(roots);
  const out = [];
  const push = (list, depth) => {
    for (const n of list) {
      out.push({ code: n.code, depth, name_i18n: n.name_i18n, blocked: blocked.has(n.code) });
      push(n.children, depth + 1);
    }
  };
  push(roots, 0);
  return out;
}

function CategoriesPage({ toast }) {
  const t = useT();
  const { lang } = useLang();
  const [rows, setRows] = useState(null);
  const [expanded, setExpanded] = useState(() => new Set());
  const [edit, setEdit] = useState(null);          // row being edited
  const [creating, setCreating] = useState(null);  // { parent_code } or null

  const reload = async () => setRows(await YY_ADMIN.get('/categories'));
  useEffect(() => { reload(); }, []);

  const tree = useMemo(() => rows ? buildCategoryTree(rows) : [], [rows]);

  const toggleExpand = (code) => setExpanded(prev => {
    const next = new Set(prev);
    if (next.has(code)) next.delete(code); else next.add(code);
    return next;
  });

  const expandAll = () => {
    if (!rows) return;
    setExpanded(new Set(rows.filter(r => !r.parent_code || rows.some(c => c.parent_code === r.code)).map(r => r.code)));
  };
  const collapseAll = () => setExpanded(new Set());

  async function remove(code) {
    if (!confirm(t('categories.delete_confirm', { code }))) return;
    try { await YY_ADMIN.del('/categories/' + code); toast(t('common.deleted')); reload(); } catch (e) { toast(e.message, true); }
  }

  function Row({ node, depth }) {
    const hasKids = node.children.length > 0;
    const open = expanded.has(node.code);
    return (
      <>
        <tr>
          <td className="mono" style={{ paddingLeft: 8 + depth * 22 }}>
            {hasKids ? (
              <button className="btn sm" style={{ padding: '0 6px', marginRight: 6 }} onClick={() => toggleExpand(node.code)} aria-label="toggle">
                {open ? '▾' : '▸'}
              </button>
            ) : (
              <span style={{ display: 'inline-block', width: 22 }}/>
            )}
            {node.code}
            {hasKids && <span style={{ marginLeft: 6, fontSize: 11, color: 'var(--fg-subtle)' }}>· {t('categories.has_children', { n: node.children.length })}</span>}
          </td>
          <td>{pickI18n(node.name_i18n, lang) || '—'}</td>
          <td>{node.name_i18n?.en || ''}</td>
          <td>{node.sort_order}</td>
          <td><span className={'pill ' + (node.active ? 'ok' : 'off')}>{node.active ? t('common.active') : t('common.off')}</span></td>
          <td>
            <div className="row-actions">
              <button onClick={() => setCreating({ parent_code: node.code })}>{t('categories.new_child')}</button>
              <button onClick={() => setEdit(node)}>{t('common.edit')}</button>
              <button className="danger" onClick={() => remove(node.code)}>{t('common.delete')}</button>
            </div>
          </td>
        </tr>
        {open && node.children.map(c => <Row key={c.code} node={c} depth={depth + 1} />)}
      </>
    );
  }

  return (
    <>
      <div className="page-head">
        <div><h1>{t('categories.title')}</h1><div className="sub">{rows ? `${rows.length} ${t('categories.count')}` : '…'}</div></div>
        <div className="actions">
          <button className="btn sm" onClick={expandAll}>{t('categories.expand_all')}</button>
          <button className="btn sm" onClick={collapseAll}>{t('categories.collapse_all')}</button>
          <button className="btn primary" onClick={() => setCreating({ parent_code: null })}>{t('categories.new_top')}</button>
        </div>
      </div>
      <div className="panel"><div className="panel-body">
        {rows === null ? <div className="loading">{t('common.loading')}</div> :
         tree.length === 0 ? <div className="empty">{t('categories.tree_empty')}</div> :
         <table className="t">
           <thead><tr>
             <th>{t('common.code')}</th>
             <th>{t('common.name')}</th>
             <th>EN</th>
             <th>{t('common.sort_order')}</th>
             <th>{t('common.state')}</th>
             <th></th>
           </tr></thead>
           <tbody>
             {tree.map(n => <Row key={n.code} node={n} depth={0} />)}
           </tbody>
         </table>}
      </div></div>
      {(edit || creating) && (
        <CategoryEditor
          row={edit} creating={!!creating}
          allRows={rows || []}
          defaultParentCode={creating?.parent_code ?? null}
          onClose={() => { setEdit(null); setCreating(null); }}
          onSaved={async () => { setEdit(null); setCreating(null); await reload(); toast(t('common.saved')); }}
          onError={(m) => toast(m, true)}
        />
      )}
    </>
  );
}

function CategoryEditor({ row, creating, allRows, defaultParentCode, onClose, onSaved, onError }) {
  const t = useT();
  const { lang } = useLang();
  const [form, setForm] = useState(() => ({
    code:        row?.code || '',
    parent_code: row?.parent_code ?? (creating ? defaultParentCode : null),
    name_i18n:   row?.name_i18n || {},
    sort_order:  row?.sort_order ?? 99,
    active:      row?.active ?? true,
  }));
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
  const [busy, setBusy] = useState(false);

  // Build parent-picker options excluding self and descendants.
  const tree = useMemo(() => buildCategoryTree(allRows), [allRows]);
  const parentOptions = useMemo(() => flattenForParentPicker(tree, row?.code || '__none__'), [tree, row]);

  async function save() {
    if (!form.code) return onError(t('categories.code_required'));
    setBusy(true);
    try {
      const body = {
        ...form,
        parent_code: form.parent_code || null,
        sort_order:  Number(form.sort_order),
      };
      if (creating) await YY_ADMIN.post('/categories', body);
      else          await YY_ADMIN.put('/categories/' + row.code, body);
      onSaved();
    } catch (e) { onError(e.message); }
    finally { setBusy(false); }
  }

  return (
    <Modal title={creating ? t('categories.new') : t('common.edit') + ' · ' + row.code} onClose={onClose} onSave={save} disabled={busy}>
      <div className="form-grid">
        <Field label={t('categories.field_code')}>
          <input disabled={!creating} value={form.code} onChange={e=>set('code', e.target.value)} placeholder="e.g. fashion or fashion/tops"/>
        </Field>
        <Field label={t('categories.field_parent')}>
          <select value={form.parent_code || ''} onChange={e=>set('parent_code', e.target.value || null)}>
            <option value="">{t('categories.field_parent_none')}</option>
            {parentOptions.map(o => (
              <option key={o.code} value={o.code} disabled={o.blocked}>
                {'— '.repeat(o.depth) + (pickI18n(o.name_i18n, lang) || o.code)}
              </option>
            ))}
          </select>
        </Field>
        <Field label={t('categories.field_sort')}><input type="number" value={form.sort_order} onChange={e=>set('sort_order', e.target.value)}/></Field>
        <Field label={t('categories.field_active')}><div style={{ paddingTop: 6 }}><label><input type="checkbox" checked={!!form.active} onChange={e=>set('active', e.target.checked)}/> {t('common.live')}</label></div></Field>
        <Field label={t('categories.field_name')} full><I18nField value={form.name_i18n} onChange={v=>set('name_i18n', v)}/></Field>
      </div>
    </Modal>
  );
}

// ============================ tags ==========================================
function TagsPage({ toast }) {
  const t = useT();
  const { lang } = useLang();
  const [rows, setRows] = useState(null);
  const [edit, setEdit] = useState(null);
  const [creating, setCreating] = useState(false);
  const reload = async () => {
    try {
      const data = await YY_ADMIN.get('/tags');
      setRows(Array.isArray(data) ? data : []);
    } catch (e) {
      console.error('[TagsPage] GET /tags failed:', e);
      if (toast) toast('tags: ' + (e && e.message ? e.message : 'error'), true);
      setRows([]); // render empty state instead of hanging on "loading…"
    }
  };
  useEffect(() => { reload(); }, []);

  async function remove(code) {
    if (!confirm(t('tags.delete_confirm', { code }))) return;
    try { await YY_ADMIN.del('/tags/' + code); toast(t('common.deleted')); reload(); } catch (e) { toast(e.message, true); }
  }

  return (
    <>
      <div className="page-head">
        <div><h1>{t('tags.title')}</h1><div className="sub">{t('tags.sub')} · {rows ? `${rows.length} ${t('tags.count')}` : '…'}</div></div>
        <div className="actions"><button className="btn primary" onClick={() => setCreating(true)}>{t('tags.new')}</button></div>
      </div>
      <div className="panel"><div className="panel-body">
        {rows === null ? <div className="loading">{t('common.loading')}</div> :
         rows.length === 0 ? <div className="empty">{t('common.empty')}</div> :
         <table className="t">
           <thead><tr>
             <th>{t('common.code')}</th>
             <th>{t('common.name')}</th>
             <th>EN</th>
             <th>{t('common.sort_order')}</th>
             <th>{t('common.state')}</th>
             <th></th>
           </tr></thead>
           <tbody>
             {rows.map(r => (
               <tr key={r.code}>
                 <td className="mono">{r.code}</td>
                 <td>{pickI18n(r.name_i18n, lang) || '—'}</td>
                 <td>{r.name_i18n?.en || ''}</td>
                 <td>{r.sort_order}</td>
                 <td><span className={'pill ' + (r.active ? 'ok' : 'off')}>{r.active ? t('common.active') : t('common.off')}</span></td>
                 <td>
                   <div className="row-actions">
                     <button onClick={() => setEdit(r)}>{t('common.edit')}</button>
                     <button className="danger" onClick={() => remove(r.code)}>{t('common.delete')}</button>
                   </div>
                 </td>
               </tr>
             ))}
           </tbody>
         </table>}
      </div></div>
      {(edit || creating) && (
        <TagEditor
          row={edit} creating={creating}
          onClose={() => { setEdit(null); setCreating(false); }}
          onSaved={async () => { setEdit(null); setCreating(false); await reload(); toast(t('common.saved')); }}
          onError={(m) => toast(m, true)}
        />
      )}
    </>
  );
}

function TagEditor({ row, creating, onClose, onSaved, onError }) {
  const t = useT();
  const [form, setForm] = useState(() => ({
    code:       row?.code || '',
    name_i18n:  row?.name_i18n || {},
    sort_order: row?.sort_order ?? 99,
    active:     row?.active ?? true,
  }));
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
  const [busy, setBusy] = useState(false);

  async function save() {
    if (!form.code) return onError(t('tags.code_required'));
    setBusy(true);
    try {
      const body = { ...form, sort_order: Number(form.sort_order) };
      if (creating) await YY_ADMIN.post('/tags', body);
      else          await YY_ADMIN.put('/tags/' + row.code, body);
      onSaved();
    } catch (e) { onError(e.message); }
    finally { setBusy(false); }
  }

  return (
    <Modal title={creating ? t('tags.new') : t('common.edit') + ' · ' + row.code} onClose={onClose} onSave={save} disabled={busy}>
      <div className="form-grid">
        <Field label={t('tags.field_code')}><input disabled={!creating} value={form.code} onChange={e=>set('code', e.target.value)} placeholder="e.g. gift, limited"/></Field>
        <Field label={t('tags.field_sort')}><input type="number" value={form.sort_order} onChange={e=>set('sort_order', e.target.value)}/></Field>
        <Field label={t('tags.field_active')}>
          <div style={{ paddingTop: 6 }}><label><input type="checkbox" checked={!!form.active} onChange={e=>set('active', e.target.checked)}/> {t('common.live')}</label></div>
        </Field>
        <Field label={t('tags.field_name')} full><I18nField value={form.name_i18n} onChange={v=>set('name_i18n', v)}/></Field>
      </div>
    </Modal>
  );
}

// ============================ site_sections =================================
function SectionsPage({ toast }) {
  const t = useT();
  const { lang } = useLang();
  const [rows, setRows] = useState(null);
  const [cats, setCats] = useState([]);
  const [tags, setTags] = useState([]);
  const [themes, setThemes] = useState([]);
  const [edit, setEdit] = useState(null);
  const [creating, setCreating] = useState(false);

  const reload = async () => {
    // Harden: if any single endpoint fails (e.g. server not restarted after the
    // schema migration so /api/site-sections is still a 404), don't let the
    // whole Promise.all reject and leave the page stuck on "loading". Instead
    // fall back to empty results per call and surface the error via toast.
    const safe = (p, label) => YY_ADMIN.get(p).catch(e => {
      console.error('[SectionsPage] GET ' + p + ' failed:', e);
      if (toast) toast((label || p) + ': ' + (e && e.message ? e.message : 'error'), true);
      return [];
    });
    const [s, c, tg, th] = await Promise.all([
      safe('/site-sections', 'site-sections'),
      safe('/categories',    'categories'),
      safe('/tags',          'tags'),
      safe('/themes',        'themes'),
    ]);
    setRows(Array.isArray(s) ? s : []);
    setCats(Array.isArray(c) ? c : []);
    setTags(Array.isArray(tg) ? tg : []);
    setThemes(Array.isArray(th) ? th : []);
  };
  useEffect(() => { reload(); }, []);

  async function remove(id) {
    if (!confirm(t('sections.delete_confirm', { id }))) return;
    try { await YY_ADMIN.del('/site-sections/' + id); toast(t('common.deleted')); reload(); } catch (e) { toast(e.message, true); }
  }

  return (
    <>
      <div className="page-head">
        <div><h1>{t('sections.title')}</h1><div className="sub">{t('sections.sub')} · {rows ? `${rows.length} ${t('sections.count')}` : '…'}</div></div>
        <div className="actions"><button className="btn primary" onClick={() => setCreating(true)}>{t('sections.new')}</button></div>
      </div>
      <div className="panel"><div className="panel-body">
        {rows === null ? <div className="loading">{t('common.loading')}</div> :
         rows.length === 0 ? <div className="empty">{t('common.empty')}</div> :
         <table className="t">
           <thead><tr>
             <th>{t('common.id')}</th>
             <th>{t('common.name')}</th>
             <th>{t('sections.field_layout')}</th>
             <th>{t('sections.field_skin')}</th>
             <th>{t('sections.field_include')}</th>
             <th>{t('common.sort_order')}</th>
             <th>{t('common.state')}</th>
             <th></th>
           </tr></thead>
           <tbody>
             {rows.map(s => {
               const inc = Array.isArray(s.include) ? s.include : [];
               const ncat = inc.filter(r => r.type === 'category').length;
               const ntag = inc.filter(r => r.type === 'tag').length;
               const nvrt = inc.filter(r => r.type === 'virtual').length;
               return (
                 <tr key={s.id}>
                   <td className="mono">{s.id}</td>
                   <td>{pickI18n(s.name_i18n, lang) || '—'}</td>
                   <td>{s.layout || 'home'}</td>
                   <td>{s.skin ? <span className="pill">{s.skin}</span> : <span style={{ color: 'var(--fg-subtle)' }}>—</span>}</td>
                   <td style={{ fontSize: 12, color: 'var(--fg-muted)' }}>
                     {ncat ? <span className="pill" style={{ marginRight: 4 }}>cat×{ncat}</span> : null}
                     {ntag ? <span className="pill" style={{ marginRight: 4 }}>tag×{ntag}</span> : null}
                     {nvrt ? <span className="pill">virt×{nvrt}</span> : null}
                     {!ncat && !ntag && !nvrt && '—'}
                   </td>
                   <td>{s.sort_order}</td>
                   <td><span className={'pill ' + (s.active ? 'ok' : 'off')}>{s.active ? t('common.active') : t('common.off')}</span></td>
                   <td>
                     <div className="row-actions">
                       <a className="btn sm" href={'/#/section/' + s.id} target="_blank" rel="noopener" style={{ textDecoration: 'none' }}>{t('sections.preview')} ↗</a>
                       <button onClick={() => setEdit(s)}>{t('common.edit')}</button>
                       <button className="danger" onClick={() => remove(s.id)}>{t('common.delete')}</button>
                     </div>
                   </td>
                 </tr>
               );
             })}
           </tbody>
         </table>}
      </div></div>
      {(edit || creating) && (
        <SectionEditor
          row={edit} creating={creating}
          cats={cats} tags={tags} themes={themes}
          onClose={() => { setEdit(null); setCreating(false); }}
          onSaved={async () => { setEdit(null); setCreating(false); await reload(); toast(t('common.saved')); }}
          onError={(m) => toast(m, true)}
        />
      )}
    </>
  );
}

function SectionEditor({ row, creating, cats, tags, themes, onClose, onSaved, onError }) {
  const t = useT();
  const { lang } = useLang();
  const emptyHero = {
    title_i18n: {}, subtitle_i18n: {}, cta_label_i18n: {},
    cta_target: '', intro_i18n: {}, product_ids: [], picks: [],
  };
  const [form, setForm] = useState(() => {
    const rawHero = row?.hero && typeof row.hero === 'object' ? row.hero : {};
    // Migrate legacy product_ids → picks when picks is missing.
    const picksFromLegacy = Array.isArray(rawHero.product_ids)
      ? rawHero.product_ids.map(pid => ({ product_id: pid, image_url: null, title_i18n: {}, subtitle_i18n: {}, cta_label_i18n: {} }))
      : [];
    const hero = {
      ...emptyHero,
      ...rawHero,
      picks: Array.isArray(rawHero.picks) && rawHero.picks.length ? rawHero.picks : picksFromLegacy,
    };
    return {
      id:         row?.id || '',
      name_i18n:  row?.name_i18n || {},
      layout:     row?.layout || 'home',
      skin:       row?.skin || '',
      accent:     row?.accent || '',
      include:    Array.isArray(row?.include) ? row.include : [],
      hero,
      sort_order: row?.sort_order ?? 99,
      active:     row?.active ?? true,
    };
  });
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
  const setHero = (k, v) => setForm(f => ({ ...f, hero: { ...f.hero, [k]: v } }));
  const [busy, setBusy] = useState(false);

  // Build lookups from include[].
  const tree = useMemo(() => buildCategoryTree(cats), [cats]);
  const flatCats = useMemo(() => flattenForParentPicker(tree, '__none__'), [tree]);

  const catRules = form.include.filter(r => r.type === 'category');
  const tagRules = form.include.filter(r => r.type === 'tag');
  const virtRules = form.include.filter(r => r.type === 'virtual');

  const toggleCat = (code) => {
    const exists = catRules.find(r => r.code === code);
    if (exists) set('include', form.include.filter(r => !(r.type === 'category' && r.code === code)));
    else set('include', [...form.include, { type: 'category', code, include_descendants: true }]);
  };
  const setCatDescendants = (code, flag) => {
    set('include', form.include.map(r =>
      r.type === 'category' && r.code === code ? { ...r, include_descendants: flag } : r
    ));
  };
  const toggleTag = (code) => {
    const exists = tagRules.find(r => r.code === code);
    if (exists) set('include', form.include.filter(r => !(r.type === 'tag' && r.code === code)));
    else set('include', [...form.include, { type: 'tag', code }]);
  };
  const toggleVirtual = (code, extra) => {
    const exists = virtRules.find(r => r.code === code);
    if (exists) set('include', form.include.filter(r => !(r.type === 'virtual' && r.code === code)));
    else set('include', [...form.include, { type: 'virtual', code, ...(extra || {}) }]);
  };

  // --- hero.picks helpers ----------------------------------------------
  const picks = Array.isArray(form.hero.picks) ? form.hero.picks : [];
  const setPicks = (arr) => setHero('picks', arr);
  const setPickAt = (i, patch) => {
    const next = picks.slice();
    next[i] = { ...next[i], ...patch };
    setPicks(next);
  };
  const addPick = () => setPicks([...picks, {
    product_id: '', image_url: null,
    title_i18n: {}, subtitle_i18n: {}, cta_label_i18n: {},
  }]);
  const removePick = (i) => { const n = picks.slice(); n.splice(i, 1); setPicks(n); };
  const movePick = (i, delta) => {
    const j = i + delta;
    if (j < 0 || j >= picks.length) return;
    const n = picks.slice();
    [n[i], n[j]] = [n[j], n[i]];
    setPicks(n);
  };

  async function save() {
    if (!form.id) return onError(t('sections.id_required'));
    setBusy(true);
    try {
      // Derive product_ids from picks for back-compat (storefront fallback path).
      const pick_ids = (Array.isArray(form.hero.picks) ? form.hero.picks : [])
        .map(p => p && p.product_id).filter(Boolean);
      const body = {
        ...form,
        skin:       form.skin || null,
        accent:     form.accent || null,
        sort_order: Number(form.sort_order),
        hero:       { ...form.hero, product_ids: pick_ids },
      };
      if (creating) await YY_ADMIN.post('/site-sections', body);
      else          await YY_ADMIN.put('/site-sections/' + row.id, body);
      onSaved();
    } catch (e) { onError(e.message); }
    finally { setBusy(false); }
  }

  const sectionHeader = (txt) => (
    <div style={{
      gridColumn: '1 / -1',
      borderTop: '1px solid var(--border)',
      paddingTop: 14,
      marginTop: 4,
      fontSize: 12,
      letterSpacing: '0.12em',
      textTransform: 'uppercase',
      color: 'var(--fg-muted)',
    }}>{txt}</div>
  );

  return (
    <Modal title={creating ? t('sections.new') : t('common.edit') + ' · ' + row.id} onClose={onClose} onSave={save} disabled={busy}>
      <div className="form-grid">
        <Field label={t('sections.field_id')}><input disabled={!creating} value={form.id} onChange={e=>set('id', e.target.value)} placeholder="e.g. gift, new, fashion"/></Field>
        <Field label={t('sections.field_sort')}><input type="number" value={form.sort_order} onChange={e=>set('sort_order', e.target.value)}/></Field>
        <Field label={t('sections.field_active')}>
          <div style={{ paddingTop: 6 }}><label><input type="checkbox" checked={!!form.active} onChange={e=>set('active', e.target.checked)}/> {t('common.live')}</label></div>
        </Field>
        <Field label={t('sections.field_layout')}>
          <select value={form.layout} onChange={e=>set('layout', e.target.value)}>
            <option value="home">{t('sections.layout_home')}</option>
            <option value="culture">{t('sections.layout_culture')}</option>
          </select>
        </Field>
        <Field label={t('sections.field_skin')}>
          <select value={form.skin} onChange={e=>set('skin', e.target.value)}>
            <option value="">{t('sections.field_skin_none')}</option>
            {themes.map(th => <option key={th.code} value={th.code}>{pickI18n(th.name_i18n, lang) || th.code}</option>)}
          </select>
        </Field>
        <Field label={t('sections.field_accent')}>
          <input value={form.accent} onChange={e=>set('accent', e.target.value)} placeholder="#c83232 (optional)"/>
        </Field>
        <Field label={t('sections.field_name')} full><I18nField value={form.name_i18n} onChange={v=>set('name_i18n', v)}/></Field>

        {sectionHeader(t('sections.field_include'))}
        <div style={{ gridColumn: '1 / -1', fontSize: 12, color: 'var(--fg-muted)' }}>{t('sections.include_help')}</div>

        <Field label={t('sections.include_cats')} full>
          <div style={{ maxHeight: 220, overflow: 'auto', border: '1px solid var(--border)', borderRadius: 4, padding: 8, background: 'var(--bg-alt)' }}>
            {flatCats.length === 0 ? <div style={{ fontSize: 12, color: 'var(--fg-subtle)' }}>—</div> : flatCats.map(o => {
              const rule = catRules.find(r => r.code === o.code);
              return (
                <div key={o.code} style={{ display: 'flex', alignItems: 'center', gap: 8, paddingLeft: o.depth * 16 }}>
                  <label style={{ display: 'flex', alignItems: 'center', gap: 6, flex: 1 }}>
                    <input type="checkbox" checked={!!rule} onChange={() => toggleCat(o.code)}/>
                    <span className="mono" style={{ fontSize: 12 }}>{o.code}</span>
                    <span style={{ color: 'var(--fg-muted)', fontSize: 12 }}>{pickI18n(o.name_i18n, lang) || ''}</span>
                  </label>
                  {rule && (
                    <label style={{ fontSize: 11, color: 'var(--fg-muted)' }}>
                      <input type="checkbox" checked={!!rule.include_descendants} onChange={e => setCatDescendants(o.code, e.target.checked)}/> {t('sections.include_desc')}
                    </label>
                  )}
                </div>
              );
            })}
          </div>
        </Field>

        <Field label={t('sections.include_tags')} full>
          <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
            {tags.length === 0 ? <div style={{ fontSize: 12, color: 'var(--fg-subtle)' }}>—</div> : tags.map(tg => {
              const on = !!tagRules.find(r => r.code === tg.code);
              return (
                <label key={tg.code} className={'pill ' + (on ? 'ok' : '')} style={{ cursor: 'pointer', userSelect: 'none' }}>
                  <input type="checkbox" checked={on} onChange={() => toggleTag(tg.code)} style={{ marginRight: 4 }}/>
                  {pickI18n(tg.name_i18n, lang) || tg.code}
                </label>
              );
            })}
          </div>
        </Field>

        <Field label={t('sections.include_virtual')} full>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 13 }}>
            <label><input type="checkbox" checked={!!virtRules.find(r => r.code === 'sale')} onChange={() => toggleVirtual('sale')}/> {t('sections.virtual_sale')}</label>
            <label><input type="checkbox" checked={!!virtRules.find(r => r.code === 'featured')} onChange={() => toggleVirtual('featured')}/> {t('sections.virtual_featured')}</label>
            <label>
              <input type="checkbox" checked={!!virtRules.find(r => r.code === 'new')} onChange={() => toggleVirtual('new', { days: 30 })}/>
              {' '}{t('sections.virtual_new', { n: (virtRules.find(r => r.code === 'new')?.days || 30) })}
            </label>
          </div>
        </Field>

        {sectionHeader(t('sections.field_hero'))}
        <Field label={t('sections.hero_title')} full><I18nField value={form.hero.title_i18n || {}} onChange={v=>setHero('title_i18n', v)}/></Field>
        <Field label={t('sections.hero_subtitle')} full><I18nField value={form.hero.subtitle_i18n || {}} onChange={v=>setHero('subtitle_i18n', v)} textarea/></Field>
        <Field label={t('sections.hero_cta')} full><I18nField value={form.hero.cta_label_i18n || {}} onChange={v=>setHero('cta_label_i18n', v)}/></Field>
        <Field label={t('sections.hero_cta_target')}><input value={form.hero.cta_target || ''} onChange={e=>setHero('cta_target', e.target.value)} placeholder="e.g. #/section/gift"/></Field>
        <Field label={t('sections.hero_picks')} full>
          <div style={{ fontSize: 12, color: 'var(--fg-muted)', marginBottom: 8 }}>{t('sections.hero_picks_help')}</div>
          {picks.length === 0 ? (
            <div style={{ fontSize: 12, color: 'var(--fg-subtle)', padding: '10px 0' }}>{t('sections.hero_picks_empty')}</div>
          ) : (
            <div style={{ display: 'grid', gap: 12 }}>
              {picks.map((p, i) => (
                <div key={i} style={{ border: '1px solid var(--border)', borderRadius: 4, padding: 12, background: '#faf6ee' }}>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
                    <span style={{ fontSize: 11, color: 'var(--fg-muted)', letterSpacing: '0.08em', textTransform: 'uppercase' }}>
                      #{i + 1}
                    </span>
                    <input type="text" value={p.product_id || ''}
                           onChange={e => setPickAt(i, { product_id: e.target.value.trim() })}
                           placeholder="product id, e.g. p07"
                           style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12, padding: '4px 8px', minWidth: 140 }}/>
                    <input type="text" value={p.cta_target || ''}
                           onChange={e => setPickAt(i, { cta_target: e.target.value })}
                           placeholder={t('sections.pick_cta_target_ph')}
                           style={{ flex: 1, fontSize: 12, padding: '4px 8px' }}/>
                    <div style={{ display: 'inline-flex', gap: 2 }}>
                      <button type="button" className="btn sm" disabled={i === 0}
                              onClick={() => movePick(i, -1)} style={{ padding: '2px 6px', fontSize: 11 }}>↑</button>
                      <button type="button" className="btn sm" disabled={i === picks.length - 1}
                              onClick={() => movePick(i, +1)} style={{ padding: '2px 6px', fontSize: 11 }}>↓</button>
                      <button type="button" className="btn sm danger"
                              onClick={() => removePick(i)} style={{ padding: '2px 7px', fontSize: 11 }}>×</button>
                    </div>
                  </div>

                  <div style={{ display: 'grid', gridTemplateColumns: '160px 1fr', gap: 12 }}>
                    <AssetSlot
                      label={t('sections.pick_cover')}
                      kind="picks"
                      assetKey={(form.id || 'section') + '-' + (i + 1)}
                      aspect="4 / 5"
                      url={p.image_url || null}
                      onChange={(u) => setPickAt(i, { image_url: u })}
                      onError={onError}
                      toast={(m) => { /* no top-level toast here; best-effort */ }}
                    />
                    <div style={{ display: 'grid', gap: 8 }}>
                      <div>
                        <div style={{ fontSize: 11, color: 'var(--fg-muted)', marginBottom: 4, letterSpacing: '0.06em', textTransform: 'uppercase' }}>
                          {t('sections.pick_title')}
                        </div>
                        <I18nField value={p.title_i18n || {}} onChange={v => setPickAt(i, { title_i18n: v })}/>
                      </div>
                      <div>
                        <div style={{ fontSize: 11, color: 'var(--fg-muted)', marginBottom: 4, letterSpacing: '0.06em', textTransform: 'uppercase' }}>
                          {t('sections.pick_subtitle')}
                        </div>
                        <I18nField value={p.subtitle_i18n || {}} onChange={v => setPickAt(i, { subtitle_i18n: v })} textarea/>
                      </div>
                      <div>
                        <div style={{ fontSize: 11, color: 'var(--fg-muted)', marginBottom: 4, letterSpacing: '0.06em', textTransform: 'uppercase' }}>
                          {t('sections.pick_cta')}
                        </div>
                        <I18nField value={p.cta_label_i18n || {}} onChange={v => setPickAt(i, { cta_label_i18n: v })}/>
                      </div>
                    </div>
                  </div>
                </div>
              ))}
            </div>
          )}
          <div style={{ marginTop: 10 }}>
            <button type="button" className="btn sm" onClick={addPick}>+ {t('sections.hero_picks_add')}</button>
          </div>
        </Field>
      </div>
    </Modal>
  );
}

// ============================ users =========================================
function UsersPage({ toast }) {
  const t = useT();
  const { lang } = useLang();
  const [rows, setRows] = useState(null);
  const [stats, setStats] = useState(null);
  const [q, setQ] = useState('');
  const [status, setStatus] = useState('');
  const [edit, setEdit] = useState(null);

  const reload = async () => {
    const qs = [];
    if (q) qs.push('q=' + encodeURIComponent(q));
    if (status) qs.push('status=' + encodeURIComponent(status));
    const [u, s] = await Promise.all([
      YY_ADMIN.get('/admin/users' + (qs.length ? '?' + qs.join('&') : '')),
      YY_ADMIN.get('/admin/user-stats'),
    ]);
    setRows(u); setStats(s);
  };
  useEffect(() => { reload(); }, [status]);

  async function toggleActive(u) {
    try { await YY_ADMIN.put('/admin/users/' + u.id, { is_active: !u.is_active }); reload(); toast(t('common.saved')); }
    catch (e) { toast(e.message, true); }
  }
  async function remove(u) {
    if (!confirm(t('users.delete_confirm', { email: u.email }))) return;
    try { await YY_ADMIN.del('/admin/users/' + u.id); toast(t('common.deleted')); reload(); }
    catch (e) { toast(e.message, true); }
  }
  async function resend(u) {
    try {
      const r = await YY_ADMIN.post('/admin/users/' + u.id + '/resend-verification');
      if (r.already) toast(t('users.resent_already'));
      else if (r.mail && r.mail.ok) toast(t('users.resent_ok'));
      else toast((r.mail && r.mail.reason) || t('mail.not_configured'), true);
    } catch (e) { toast(e.message, true); }
  }

  return (
    <>
      <div className="page-head">
        <div><h1>{t('users.title')}</h1><div className="sub">{rows ? `${rows.length} ${t('users.count')}` : '…'}</div></div>
        <div className="actions">
          <form onSubmit={e => { e.preventDefault(); reload(); }} style={{ display:'flex', gap: 8 }}>
            <input placeholder={t('users.search')} value={q} onChange={e=>setQ(e.target.value)}
                   style={{ padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 4, minWidth: 240 }}/>
            <button className="btn" type="submit">{t('common.search')}</button>
          </form>
          <select value={status} onChange={e=>setStatus(e.target.value)} style={{ padding: '8px 12px', border: '1px solid var(--border-strong)', borderRadius: 4, background: 'var(--bg-alt)' }}>
            <option value="">{t('users.filter_all')}</option>
            <option value="verified">{t('users.filter_verified')}</option>
            <option value="unverified">{t('users.filter_unverified')}</option>
            <option value="disabled">{t('users.filter_disabled')}</option>
          </select>
        </div>
      </div>

      <div className="kpis">
        <div className="kpi"><div className="l">{t('users.kpi_total')}</div><div className="v">{stats?.total ?? '—'}</div><div className="s">{t('users.count')}</div></div>
        <div className="kpi"><div className="l">{t('users.kpi_verified')}</div><div className="v">{stats?.verified ?? '—'}</div><div className="s">{t('users.verified_pill')}</div></div>
        <div className="kpi"><div className="l">{t('users.kpi_disabled')}</div><div className="v">{stats?.disabled ?? '—'}</div><div className="s">{t('common.disabled')}</div></div>
        <div className="kpi"><div className="l">{t('users.kpi_week')}</div><div className="v">{stats?.week ?? '—'}</div><div className="s">7d</div></div>
      </div>

      <div className="panel">
        <div className="panel-body">
          {rows === null ? <div className="loading">{t('common.loading')}</div> :
           rows.length === 0 ? <div className="empty">{t('users.no_users')}</div> :
           <table className="t">
             <thead><tr>
               <th>{t('users.col_email')}</th>
               <th>{t('users.col_name')}</th>
               <th>{t('users.col_locale')}</th>
               <th>{t('users.col_verified')}</th>
               <th>{t('common.state')}</th>
               <th>{t('users.col_admin')}</th>
               <th>{t('users.col_created')}</th>
               <th></th>
             </tr></thead>
             <tbody>
               {rows.map(u => (
                 <tr key={u.id}>
                   <td><span className="mono">{u.email}</span></td>
                   <td>{u.name || <span style={{ color:'var(--fg-muted)' }}>—</span>}</td>
                   <td>{u.locale || '—'}</td>
                   <td>
                     <span className={'pill ' + (u.email_verified ? 'ok' : 'warn')}>
                       {u.email_verified ? t('users.verified_pill') : t('users.unverified_pill')}
                     </span>
                   </td>
                   <td>
                     <span className={'pill ' + (u.is_active ? 'ok' : 'off')}>
                       {u.is_active ? t('common.active') : t('common.disabled')}
                     </span>
                   </td>
                   <td>{u.is_admin ? <span className="pill warn">admin</span> : <span style={{ color:'var(--fg-muted)' }}>—</span>}</td>
                   <td>{new Date(u.created_at).toLocaleString(lang === 'zh' ? 'zh-CN' : 'en-US')}</td>
                   <td>
                     <div className="row-actions">
                       <button onClick={() => setEdit(u)}>{t('common.edit')}</button>
                       {!u.email_verified && <button onClick={() => resend(u)}>{t('users.resend')}</button>}
                       <button onClick={() => toggleActive(u)}>{u.is_active ? t('users.disable') : t('users.enable')}</button>
                       <button className="danger" onClick={() => remove(u)}>{t('common.delete')}</button>
                     </div>
                   </td>
                 </tr>
               ))}
             </tbody>
           </table>}
        </div>
      </div>

      {edit && (
        <UserEditor
          user={edit}
          onClose={() => setEdit(null)}
          onSaved={async () => { setEdit(null); await reload(); toast(t('common.saved')); }}
          onError={(m) => toast(m, true)}
        />
      )}
    </>
  );
}

function UserEditor({ user, onClose, onSaved, onError }) {
  const t = useT();
  const { lang } = useLang();
  const [form, setForm] = useState({
    name:           user.name || '',
    phone:          user.phone || '',
    locale:         user.locale || 'zh',
    is_active:      !!user.is_active,
    is_admin:       !!user.is_admin,
    email_verified: !!user.email_verified,
  });
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
  const [busy, setBusy] = useState(false);
  const [detail, setDetail] = useState(null);

  // Pull detail w/ recent orders
  useEffect(() => {
    (async () => {
      try { setDetail(await YY_ADMIN.get('/admin/users/' + user.id)); } catch { /* ignore */ }
    })();
  }, [user.id]);

  async function save() {
    setBusy(true);
    try {
      await YY_ADMIN.put('/admin/users/' + user.id, form);
      onSaved();
    } catch (e) { onError(e.message); }
    finally { setBusy(false); }
  }

  return (
    <Modal title={t('users.edit_title', { email: user.email })} onClose={onClose} onSave={save} disabled={busy}>
      <div className="form-grid">
        <Field label={t('users.field_name')}><input value={form.name} onChange={e=>set('name', e.target.value)}/></Field>
        <Field label={t('users.field_phone')}><input value={form.phone} onChange={e=>set('phone', e.target.value)}/></Field>
        <Field label={t('users.field_locale')}>
          <select value={form.locale} onChange={e=>set('locale', e.target.value)}>
            {LANG_ORDER.map(l => <option key={l} value={l}>{l}</option>)}
          </select>
        </Field>
        <Field label=" "><span>&nbsp;</span></Field>
        <Field label={t('users.field_verified')}>
          <div style={{ paddingTop: 6 }}><label><input type="checkbox" checked={form.email_verified} onChange={e=>set('email_verified', e.target.checked)}/> {t('common.yes')}</label></div>
        </Field>
        <Field label={t('users.field_active')}>
          <div style={{ paddingTop: 6 }}><label><input type="checkbox" checked={form.is_active} onChange={e=>set('is_active', e.target.checked)}/> {t('common.yes')}</label></div>
        </Field>
        <Field label={t('users.field_admin')}>
          <div style={{ paddingTop: 6 }}><label><input type="checkbox" checked={form.is_admin} onChange={e=>set('is_admin', e.target.checked)}/> {t('common.yes')}</label></div>
        </Field>
        <Field label=" "><span>&nbsp;</span></Field>

        <Field label={t('users.recent_orders')} full>
          {detail && detail.orders && detail.orders.length > 0 ? (
            <table className="t">
              <thead><tr>
                <th>{t('orders.col_order')}</th>
                <th>{t('common.state')}</th>
                <th>{t('orders.col_total')}</th>
                <th>{t('orders.col_placed')}</th>
              </tr></thead>
              <tbody>
                {detail.orders.map(o => (
                  <tr key={o.id}>
                    <td className="mono">{o.order_no}</td>
                    <td><span className={'pill ' + (o.status === 'paid' || o.status === 'shipped' || o.status === 'delivered' ? 'ok' : o.status === 'cancelled' ? 'off' : 'warn')}>{t('status.' + o.status) || o.status}</span></td>
                    <td>${Number(o.total).toFixed(2)} {o.currency}</td>
                    <td>{new Date(o.created_at).toLocaleString(lang === 'zh' ? 'zh-CN' : 'en-US')}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          ) : (
            <div style={{ color: 'var(--fg-muted)', paddingTop: 6 }}>{t('users.no_recent_orders')}</div>
          )}
        </Field>
      </div>
    </Modal>
  );
}

// ============================ settings ======================================
function SettingsPage({ toast }) {
  const t = useT();
  const [loading, setLoading] = useState(true);
  const [busy, setBusy]       = useState(false);
  const [raw, setRaw]         = useState({ ai: {}, mail: {}, stripe: {} });
  const [eff, setEff]         = useState({ ai: {}, mail: {}, stripe: {} });
  const [testTo, setTestTo]   = useState('');

  const ai     = raw.ai     || {};
  const mail   = raw.mail   || {};
  const stripe = raw.stripe || {};

  const reload = useCallback(async () => {
    setLoading(true);
    try {
      const r = await YY_ADMIN.get('/admin/settings');
      setRaw({
        ai:     r.raw?.ai     || {},
        mail:   r.raw?.mail   || {},
        stripe: r.raw?.stripe || {},
      });
      setEff(r.effective || { ai: {}, mail: {}, stripe: {} });
    } catch (e) { toast(e.message, true); }
    finally { setLoading(false); }
  }, [toast]);

  useEffect(() => { reload(); }, [reload]);

  const setAi     = (k, v) => setRaw(r => ({ ...r, ai:     { ...r.ai,     [k]: v } }));
  const setMail   = (k, v) => setRaw(r => ({ ...r, mail:   { ...r.mail,   [k]: v } }));
  const setStripe = (k, v) => setRaw(r => ({ ...r, stripe: { ...r.stripe, [k]: v } }));

  async function save() {
    setBusy(true);
    try {
      const r = await YY_ADMIN.put('/admin/settings', raw);
      setEff(r.effective || {});
      toast(t('settings.saved'));
    } catch (e) { toast(e.message, true); }
    finally { setBusy(false); }
  }

  async function testMail() {
    if (!testTo) return;
    setBusy(true);
    try {
      const r = await YY_ADMIN.post('/admin/settings/test-mail', { to: testTo });
      if (r.ok) toast(t('settings.test_ok') + ' (' + (r.via || '') + ')');
      else toast(r.reason || 'error', true);
    } catch (e) { toast(e.message, true); }
    finally { setBusy(false); }
  }

  const section = (title) => (
    <div style={{
      gridColumn: '1 / -1',
      fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase',
      color: 'var(--fg-muted)', borderTop: '1px solid var(--border)',
      paddingTop: 14, marginTop: 6,
    }}>{title}</div>
  );

  const ph = t('settings.empty_placeholder');

  if (loading) return <div><h2>{t('settings.title')}</h2><div>{t('common.loading')}</div></div>;

  return (
    <>
      <div className="page-head">
        <div>
          <h2>{t('settings.title')}</h2>
          <div className="sub">{t('settings.sub')}</div>
        </div>
        <div>
          <button className="btn primary" disabled={busy} onClick={save}>{t('settings.save')}</button>
        </div>
      </div>

      <div className="card">
        <div className="form-grid">
          {section(t('settings.sec_ai'))}
          <Field label={t('settings.ai_base')}>
            <input value={ai.base_url || ''} placeholder={ph + ' ' + (eff.ai?.base_url || '')}
                   onChange={e => setAi('base_url', e.target.value)}/>
          </Field>
          <Field label={t('settings.ai_key')}>
            <input value={ai.api_key || ''} placeholder={ph} style={{ fontFamily:'ui-monospace, monospace' }}
                   onChange={e => setAi('api_key', e.target.value)}/>
          </Field>
          <Field label={t('settings.ai_text_model')}>
            <input value={ai.text_model || ''} placeholder={ph + ' ' + (eff.ai?.text_model || '')}
                   onChange={e => setAi('text_model', e.target.value)}/>
          </Field>
          <Field label={t('settings.ai_image_model')}>
            <input value={ai.image_model || ''} placeholder={ph + ' ' + (eff.ai?.image_model || '')}
                   onChange={e => setAi('image_model', e.target.value)}/>
          </Field>

          {section(t('settings.sec_mail'))}
          <Field label={t('settings.mail_provider')}>
            <select value={mail.provider || ''} onChange={e => setMail('provider', e.target.value || null)}>
              <option value="">auto ({eff.mail?.provider || 'none'})</option>
              <option value="none">{t('settings.mail_provider_none')}</option>
              <option value="resend">{t('settings.mail_provider_resend')}</option>
              <option value="smtp">{t('settings.mail_provider_smtp')}</option>
            </select>
          </Field>
          <Field label={t('settings.mail_from')}>
            <input value={mail.from || ''} placeholder={ph + ' ' + (eff.mail?.from || '')}
                   onChange={e => setMail('from', e.target.value)}/>
          </Field>
          <Field label={t('settings.resend_key')}>
            <input value={mail.resend_key || ''} placeholder={ph} style={{ fontFamily:'ui-monospace, monospace' }}
                   onChange={e => setMail('resend_key', e.target.value)}/>
          </Field>

          <Field label={t('settings.smtp_host')}>
            <input value={mail.smtp_host || ''} placeholder={ph + ' ' + (eff.mail?.smtp_host || '')}
                   onChange={e => setMail('smtp_host', e.target.value)}/>
          </Field>
          <Field label={t('settings.smtp_port')}>
            <input type="number" value={mail.smtp_port ?? ''} placeholder={String(eff.mail?.smtp_port ?? '')}
                   onChange={e => setMail('smtp_port', e.target.value === '' ? null : Number(e.target.value))}/>
          </Field>
          <Field label={t('settings.smtp_user')}>
            <input value={mail.smtp_user || ''} placeholder={ph + ' ' + (eff.mail?.smtp_user || '')}
                   onChange={e => setMail('smtp_user', e.target.value)}/>
          </Field>
          <Field label={t('settings.smtp_pass')}>
            <input value={mail.smtp_pass || ''} placeholder={ph} style={{ fontFamily:'ui-monospace, monospace' }}
                   onChange={e => setMail('smtp_pass', e.target.value)}/>
          </Field>
          <Field label=" " full>
            <label style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
              <input type="checkbox" checked={!!mail.smtp_secure}
                     onChange={e => setMail('smtp_secure', e.target.checked)}/>
              {t('settings.smtp_secure')}
            </label>
            <div style={{ fontSize: 12, color: 'var(--fg-muted)', marginTop: 6 }}>{t('settings.smtp_hint')}</div>
          </Field>

          <Field label={t('settings.test_to')} full>
            <div style={{ display: 'flex', gap: 8 }}>
              <input style={{ flex: 1 }} value={testTo} onChange={e => setTestTo(e.target.value)}
                     placeholder="test@example.com"/>
              <button className="btn" disabled={busy || !testTo} onClick={testMail}>
                {t('settings.test_mail')}
              </button>
            </div>
          </Field>

          {section(t('settings.sec_stripe'))}
          <Field label=" " full>
            <div style={{ fontSize: 12, color:'var(--fg-muted)', marginBottom: 4 }}>{t('settings.stripe_hint')}</div>
          </Field>
          <Field label={t('settings.stripe_mode')} full>
            <div style={{ display:'flex', gap:16 }}>
              <label style={{ display:'inline-flex', alignItems:'center', gap:6, cursor:'pointer' }}>
                <input type="radio" name="stripeMode" checked={(stripe.mode || 'live') === 'live'}
                       onChange={() => setStripe('mode', 'live')}/>
                <span style={{ fontSize: 13 }}>{t('settings.stripe_mode_live')}</span>
              </label>
              <label style={{ display:'inline-flex', alignItems:'center', gap:6, cursor:'pointer' }}>
                <input type="radio" name="stripeMode" checked={stripe.mode === 'test'}
                       onChange={() => setStripe('mode', 'test')}/>
                <span style={{ fontSize: 13 }}>{t('settings.stripe_mode_test')}</span>
              </label>
              <span style={{ fontSize: 12, color:'var(--fg-muted)', marginLeft: 8 }}>
                effective: <code>{eff.stripe?.mode || 'live'}</code>
              </span>
            </div>
          </Field>
          <Field label={t('settings.stripe_pk')} full>
            <input style={{ width:'100%', fontFamily:'ui-monospace, monospace' }}
                   value={stripe.publishable_key || ''}
                   placeholder={ph + ' pk_live_…'}
                   onChange={e => setStripe('publishable_key', e.target.value)}/>
          </Field>
          <Field label={t('settings.stripe_sk')} full>
            <input style={{ width:'100%', fontFamily:'ui-monospace, monospace' }}
                   value={stripe.secret_key || ''}
                   placeholder={ph + ' sk_live_… / rk_live_…'}
                   onChange={e => setStripe('secret_key', e.target.value)}/>
          </Field>
          <Field label={t('settings.stripe_pk_test')} full>
            <input style={{ width:'100%', fontFamily:'ui-monospace, monospace' }}
                   value={stripe.publishable_key_test || ''}
                   placeholder={ph + ' pk_test_…'}
                   onChange={e => setStripe('publishable_key_test', e.target.value)}/>
          </Field>
          <Field label={t('settings.stripe_sk_test')} full>
            <input style={{ width:'100%', fontFamily:'ui-monospace, monospace' }}
                   value={stripe.secret_key_test || ''}
                   placeholder={ph + ' sk_test_… / rk_test_…'}
                   onChange={e => setStripe('secret_key_test', e.target.value)}/>
          </Field>
          <Field label={t('settings.stripe_whsec')} full>
            <input style={{ width:'100%', fontFamily:'ui-monospace, monospace' }}
                   value={stripe.webhook_secret || ''}
                   placeholder={ph + ' whsec_…'}
                   onChange={e => setStripe('webhook_secret', e.target.value)}/>
          </Field>
          <Field label={t('settings.stripe_currency')}>
            <input value={stripe.currency || ''} placeholder={ph + ' ' + (eff.stripe?.currency || 'usd')}
                   onChange={e => setStripe('currency', e.target.value.toLowerCase())}/>
          </Field>
        </div>
      </div>
    </>
  );
}

// ============================ Deploy =========================================
function DeployPage({ toast }) {
  const t = useT();
  const { lang } = useLang();
  const [cfg, setCfg]       = useState(null);
  const [status, setStatus] = useState(null);
  const [log, setLog]       = useState('');
  const [running, setRunning] = useState(false);
  const [busy, setBusy]     = useState(false);
  const logRef = useRef(null);

  const txt = (zh, en) => (lang === 'zh' ? zh : en);

  async function refresh() {
    try {
      const r = await fetch('/api/admin/deploy/status').then(r => r.json());
      setCfg(r.config || null);
      setStatus(r.latest || null);
      setRunning(r.latest?.status === 'running');
    } catch (e) { /* noop */ }
    try {
      const r = await fetch('/api/admin/deploy/log').then(r => r.text());
      setLog(r || '');
      // auto-scroll log to bottom
      requestAnimationFrame(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; });
    } catch (e) { /* noop */ }
  }

  useEffect(() => {
    refresh();
    const id = setInterval(refresh, 2500);
    return () => clearInterval(id);
  }, []);

  async function trigger() {
    if (busy || running) return;
    setBusy(true);
    try {
      const r = await fetch('/api/admin/deploy/run', { method: 'POST' }).then(r => r.json());
      if (r.error) {
        toast(txt('启动失败:', 'Failed: ') + r.error);
      } else {
        toast(txt('部署已开始', 'Deploy started'));
        setRunning(true);
        setTimeout(refresh, 500);
      }
    } catch (e) {
      toast(txt('启动失败:', 'Failed: ') + e.message);
    } finally {
      setBusy(false);
    }
  }

  const elapsed = status?.elapsed != null ? `${status.elapsed}s` : (status?.start ? `${Math.max(0, Math.floor(Date.now()/1000) - status.start)}s` : '—');
  const statusBadge = ({
    running: { color:'#0ea5e9', label: txt('部署中', 'Running') },
    ok:      { color:'#16a34a', label: txt('成功',   'OK') },
    fail:    { color:'#dc2626', label: txt('失败',   'Failed') },
  })[status?.status] || { color:'#64748b', label: txt('未运行', 'Idle') };

  return (
    <>
      <div className="card-hd">
        <h2>{txt('部署 / Deploy', 'Deploy')}</h2>
        <p style={{ margin: '4px 0 0', color: '#64748b' }}>
          {txt('把当前代码推到生产服务器并重启服务。数据库与开发环境共用同一套 Supabase。',
               'Push the current code to the production server and restart the service. Database is the same Supabase as development.')}
        </p>
      </div>

      <div className="card" style={{ padding: 16, marginBottom: 12 }}>
        <div style={{ display:'grid', gridTemplateColumns:'auto 1fr', gap:'8px 16px', fontSize:14 }}>
          <div style={{ color:'#64748b' }}>{txt('目标主机','Server')}</div>
          <div><code>{cfg?.server_ip || '—'} : {cfg?.ssh_port || '—'}</code> ({txt('SSH 用户','user')}: <code>{cfg?.ssh_user || '—'}</code>)</div>

          <div style={{ color:'#64748b' }}>{txt('服务端口','App port')}</div>
          <div><code>{cfg?.app_port || '—'}</code></div>

          <div style={{ color:'#64748b' }}>{txt('远端目录','Remote dir')}</div>
          <div><code>{cfg?.remote_dir || '—'}</code></div>

          <div style={{ color:'#64748b' }}>{txt('线上地址','URL')}</div>
          <div>{cfg ? <a href={`http://${cfg.server_ip}:${cfg.app_port}`} target="_blank">http://{cfg.server_ip}:{cfg.app_port}</a> : '—'}</div>

          <div style={{ color:'#64748b' }}>{txt('上次状态','Last status')}</div>
          <div>
            <span style={{
              display:'inline-block', padding:'2px 8px', borderRadius:6,
              background: statusBadge.color + '22', color: statusBadge.color, fontWeight:600, fontSize:12
            }}>{statusBadge.label}</span>
            <span style={{ marginLeft:12, color:'#64748b' }}>
              {txt('耗时','elapsed')}: {elapsed}
              {status?.startedAt ? `  · ${txt('开始','started')}: ${new Date(status.startedAt*1000).toLocaleString()}` : ''}
            </span>
          </div>
        </div>

        <div style={{ marginTop: 16 }}>
          <button
            className="btn primary"
            onClick={trigger}
            disabled={busy || running}
            style={{ minWidth: 160 }}
          >
            {running ? txt('部署中…','Deploying…') : (busy ? '…' : txt('立即部署','Deploy now'))}
          </button>
          <button className="btn" style={{ marginLeft: 8 }} onClick={refresh} disabled={busy}>
            {txt('刷新','Refresh')}
          </button>
          {cfg && (
            <a className="btn" style={{ marginLeft: 8 }} href={`http://${cfg.server_ip}:${cfg.app_port}`} target="_blank">
              {txt('打开线上站点 ↗','Open live site ↗')}
            </a>
          )}
        </div>
      </div>

      <div className="card" style={{ padding: 0 }}>
        <div style={{ padding:'8px 12px', borderBottom:'1px solid #e5e7eb', display:'flex', justifyContent:'space-between', alignItems:'center' }}>
          <strong style={{ fontSize:13 }}>{txt('部署日志','Deploy log')}</strong>
          <span style={{ fontSize:12, color:'#64748b' }}>{txt('每 2.5 秒自动刷新','Auto-refresh every 2.5s')}</span>
        </div>
        <pre ref={logRef} style={{
          margin: 0, padding: 12, height: 480, overflow: 'auto',
          fontSize: 12, lineHeight: 1.4, background: '#0b1220', color: '#cbd5e1',
          fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
          whiteSpace: 'pre-wrap', wordBreak: 'break-word'
        }}>{log || txt('(暂无日志)','(no log yet)')}</pre>
      </div>
    </>
  );
}

// ============================ shell =========================================
const NAV = [
  { id: 'dashboard',  labelKey: 'nav.dashboard',  groupKey: 'group.overview' },
  { id: 'products',   labelKey: 'nav.products',   groupKey: 'group.catalog'  },
  { id: 'categories', labelKey: 'nav.categories', groupKey: 'group.catalog'  },
  { id: 'tags',       labelKey: 'nav.tags',       groupKey: 'group.catalog'  },
  { id: 'sections',   labelKey: 'nav.sections',   groupKey: 'group.storefront' },
  { id: 'orders',     labelKey: 'nav.orders',     groupKey: 'group.sales'    },
  { id: 'users',      labelKey: 'nav.users',      groupKey: 'group.users'    },
  { id: 'channels',   labelKey: 'nav.channels',   groupKey: 'group.storefront' },
  { id: 'themes',     labelKey: 'nav.themes',     groupKey: 'group.storefront' },
  { id: 'banners',    labelKey: 'nav.banners',    groupKey: 'group.content'  },
  { id: 'culture',    labelKey: 'nav.culture',    groupKey: 'group.content'  },
  { id: 'i18n',       labelKey: 'nav.i18n',       groupKey: 'group.content'  },
  { id: 'settings',   labelKey: 'nav.settings',   groupKey: 'group.system'   },
  { id: 'deploy',     labelKey: 'nav.deploy',     groupKey: 'group.system'   },
];

function App() {
  const [lang, setLangState] = useState(() => {
    try { return localStorage.getItem('yy_admin_lang') || 'zh'; } catch { return 'zh'; }
  });
  const setLang = (l) => {
    setLangState(l);
    try { localStorage.setItem('yy_admin_lang', l); } catch {}
  };

  // Keep <html lang> in sync for browser/CSS hints
  useEffect(() => {
    document.documentElement.lang = lang === 'zh' ? 'zh' : 'en';
    document.title = lang === 'zh' ? 'YYShop · 后台' : 'YYShop · Admin';
  }, [lang]);

  return (
    <LangCtx.Provider value={{ lang, setLang }}>
      <Shell/>
    </LangCtx.Provider>
  );
}

function Shell() {
  const t = useT();
  const { lang, setLang } = useLang();
  const [page, setPage] = useState(() => (location.hash.slice(1) || 'dashboard'));
  const [showToast, toastNode] = useToast();

  useEffect(() => { location.hash = '#' + page; }, [page]);
  useEffect(() => {
    const onHash = () => setPage(location.hash.slice(1) || 'dashboard');
    window.addEventListener('hashchange', onHash);
    return () => window.removeEventListener('hashchange', onHash);
  }, []);

  // Rebuild groups every render so labels respond to language changes.
  const groups = useMemo(() => {
    const map = {};
    for (const n of NAV) {
      const g = t(n.groupKey);
      (map[g] = map[g] || []).push(n);
    }
    return map;
  }, [lang]);

  const current = NAV.find(n => n.id === page) || NAV[0];

  return (
    <div className="app">
      <div className="brand">
        <span className="brand-mark">悦</span>
        YYShop <small>{t('app.admin')}</small>
      </div>
      <div className="topbar">
        <div className="crumbs"><strong>{t(current.groupKey)}</strong> / {t(current.labelKey)}</div>
        <div className="topbar-actions">
          <button className="btn sm" onClick={() => setLang(lang === 'zh' ? 'en' : 'zh')} title="切换语言 / Switch language">{t('app.lang_toggle')}</button>
          <a className="ext" href="/" target="_blank">{t('app.storefront_link')}</a>
        </div>
      </div>
      <aside className="side">
        {Object.entries(groups).map(([g, items]) => (
          <div key={g}>
            <div className="section">{g}</div>
            <nav>
              {items.map(i => (
                <a key={i.id} className={i.id === page ? 'on' : ''} onClick={() => setPage(i.id)}>{t(i.labelKey)}</a>
              ))}
            </nav>
          </div>
        ))}
      </aside>
      <main className="main">
        {page === 'dashboard'  && <Dashboard     goto={setPage} />}
        {page === 'products'   && <ProductsPage  toast={showToast} />}
        {page === 'categories' && <CategoriesPage toast={showToast} />}
        {page === 'tags'       && <TagsPage      toast={showToast} />}
        {page === 'sections'   && <SectionsPage  toast={showToast} />}
        {page === 'orders'     && <OrdersPage    toast={showToast} />}
        {page === 'users'      && <UsersPage     toast={showToast} />}
        {page === 'channels'   && <ChannelsPage  toast={showToast} />}
        {page === 'themes'     && <ThemesPage    toast={showToast} />}
        {page === 'banners'    && <BannersPage   toast={showToast} />}
        {page === 'culture'    && <CulturePage   toast={showToast} />}
        {page === 'i18n'       && <I18nPage      toast={showToast} />}
        {page === 'settings'   && <SettingsPage  toast={showToast} />}
        {page === 'deploy'     && <DeployPage    toast={showToast} />}
      </main>
      {toastNode}
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
