Skip to content

Commit 7c420dd

Browse files
dengzhaofunclaude
andauthored
Add banner + dialogue modules with admin dashboards (#15)
Introduces two new game-facing modules built on the standard module scaffold (service.ts / routes.ts / client-routes.ts + schema in src/schema/) plus a shared Link value object that both modules — and future click-target consumers — use to express "where a click goes". Backend (apps/server) - `modules/link/` — LinkAction value object: none | external | internal. Registry whitelists 10 active internal routes (shop, mail, lottery, check-in, dialogue, …) plus 10 pending routes (friend, guild, quest, activity, leaderboard, inventory). Each route declares its params schema so banner/dialogue forms can validate eagerly on both client and server. - `modules/banner/` — multi-slot carousel groups; each banner carries mobile+desktop image URLs, LinkAction, visibility window, and reuses mail's broadcast/multicast targeting (GIN index on target_user_ids). Admin CRUD + reorder; client API `GET /groups/:alias` resolves only aliased (= published) groups. - `modules/dialogue/` — branching NPC/tutorial scripts. Nodes graph stored as jsonb, validated on every write (start node exists, next pointers resolve, no duplicate ids, rewards reference known item definitions). Client flow: start (grants onEnter rewards) → advance (with optional optionId) → completion. Rewards granted idempotently via item_grant_logs `(source, sourceId)` pre-check. - Migration 0010 adds 4 tables with partial-unique alias indexes matching the convention used by check-in/shop/item/lottery/exchange. - Vitest coverage: link validator unit tests + banner/dialogue service integration tests against a real Neon dev branch. Admin dashboard (apps/admin) - `/banner` and `/dialogue` routes: list → create → detail/edit with a shared `LinkActionEditor` component that reads LINK_ROUTES and renders route-specific param inputs dynamically. - `ScriptEditor` uses a structured form (not JSON) with inline node and option cards, reusing shop's `ItemEntryEditor` for rewards and the shared LinkActionEditor for option click actions. - Sidebar gains Banners (GalleryHorizontal) and Dialogue (MessagesSquare). - ~125 i18n keys each in en.json / zh.json. Fixes along the way - `env.ts`: Better Auth's `auth.api.getSession` return type doesn't merge the organization plugin's `activeOrganizationId` field. Switch to `auth.$Infer.Session` + an explicit intersection for the plugin field, eliminating ~136 pre-existing TS errors across every admin route handler. - `_dashboard.tsx`: under Vite SSR, `better-auth/react`'s `useSession` triggers an "invalid hook call" because of a React dual-package hazard. Moved the session-aware logic into a mounted-guarded child so the dashboard renders correctly in the dev preview. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 738ea65 commit 7c420dd

55 files changed

Lines changed: 13034 additions & 7 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
{
22
"permissions": {
33
"allow": [
4-
"Bash(pnpm exec:*)"
4+
"Bash(pnpm exec:*)",
5+
"Bash(pnpm install:*)"
56
]
67
}
78
}

apps/admin/messages/en.json

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,5 +406,139 @@
406406
"shop_growth_only": "Growth pack only.",
407407
"shop_back_to_products": "Back to products",
408408
"shop_back_to_categories": "Back to categories",
409-
"shop_manage_stages": "Manage growth stages"
409+
"shop_manage_stages": "Manage growth stages",
410+
411+
"nav_banner": "Banners",
412+
"nav_dialogue": "Dialogue",
413+
414+
"banner_title": "Banner Groups",
415+
"banner_new_group": "New Group",
416+
"banner_edit_group": "Edit Group",
417+
"banner_empty": "No banner groups yet.",
418+
"banner_failed_load": "Failed to load banner groups:",
419+
"banner_group_created": "Group created",
420+
"banner_group_updated": "Group updated",
421+
"banner_group_deleted": "Group deleted",
422+
"banner_failed_create_group": "Failed to create group",
423+
"banner_failed_update_group": "Failed to update group",
424+
"banner_failed_delete_group": "Failed to delete group",
425+
"banner_field_alias": "Alias",
426+
"banner_field_alias_hint": "Slug used by the client app to resolve this group. Leave blank to keep it as a draft.",
427+
"banner_field_name": "Name",
428+
"banner_field_description": "Description",
429+
"banner_field_layout": "Layout",
430+
"banner_field_interval": "Auto-rotate interval (ms)",
431+
"banner_field_active": "Active",
432+
"banner_layout_carousel": "Carousel",
433+
"banner_layout_single": "Single",
434+
"banner_layout_grid": "Grid",
435+
"banner_banners_title": "Banners",
436+
"banner_banners_empty": "No banners in this group yet.",
437+
"banner_new_banner": "New Banner",
438+
"banner_edit_banner": "Edit Banner",
439+
"banner_banner_created": "Banner created",
440+
"banner_banner_updated": "Banner updated",
441+
"banner_banner_deleted": "Banner deleted",
442+
"banner_failed_create_banner": "Failed to create banner",
443+
"banner_failed_update_banner": "Failed to update banner",
444+
"banner_failed_delete_banner": "Failed to delete banner",
445+
"banner_field_banner_title": "Title",
446+
"banner_field_image_mobile": "Image URL (mobile)",
447+
"banner_field_image_desktop": "Image URL (desktop)",
448+
"banner_field_alt_text": "Alt text",
449+
"banner_field_sort_order": "Sort order",
450+
"banner_field_visible_from": "Visible from",
451+
"banner_field_visible_until": "Visible until",
452+
"banner_field_target_type": "Audience",
453+
"banner_field_target_user_ids": "End user IDs (comma or newline separated)",
454+
"banner_target_broadcast": "Broadcast (everyone)",
455+
"banner_target_multicast": "Multicast (listed users)",
456+
"banner_back_to_groups": "Back to groups",
457+
"banner_back_to_group": "Back to group",
458+
"banner_delete_group_title": "Delete this group?",
459+
"banner_delete_group_desc": "This removes the group and all its banners.",
460+
"banner_delete_banner_title": "Delete this banner?",
461+
"banner_delete_banner_desc": "This permanently removes the banner.",
462+
"banner_move_up": "Move up",
463+
"banner_move_down": "Move down",
464+
"banner_draft_badge": "Draft (no alias)",
465+
"banner_status_active": "Active",
466+
"banner_status_inactive": "Inactive",
467+
"banner_col_preview": "Preview",
468+
"banner_col_title": "Title",
469+
"banner_col_visibility": "Visibility",
470+
"banner_col_target": "Audience",
471+
"banner_col_link": "Link",
472+
"banner_col_status": "Status",
473+
"banner_col_actions": "Actions",
474+
475+
"dialogue_title": "Dialogue Scripts",
476+
"dialogue_new_script": "New Script",
477+
"dialogue_edit_script": "Edit Script",
478+
"dialogue_empty": "No dialogue scripts yet.",
479+
"dialogue_failed_load": "Failed to load dialogue scripts:",
480+
"dialogue_script_created": "Script created",
481+
"dialogue_script_updated": "Script updated",
482+
"dialogue_script_deleted": "Script deleted",
483+
"dialogue_failed_create_script": "Failed to create script",
484+
"dialogue_failed_update_script": "Failed to update script",
485+
"dialogue_failed_delete_script": "Failed to delete script",
486+
"dialogue_field_alias": "Alias",
487+
"dialogue_field_alias_hint": "Slug used by the client app to trigger this script. Leave blank to keep it as a draft.",
488+
"dialogue_field_name": "Name",
489+
"dialogue_field_description": "Description",
490+
"dialogue_field_start_node": "Start node",
491+
"dialogue_field_repeatable": "Repeatable",
492+
"dialogue_field_active": "Active",
493+
"dialogue_nodes_title": "Nodes",
494+
"dialogue_nodes_empty": "This script has no nodes yet. Add at least one.",
495+
"dialogue_add_node": "Add node",
496+
"dialogue_remove_node": "Remove node",
497+
"dialogue_node_id": "Node ID",
498+
"dialogue_node_speaker_name": "Speaker name",
499+
"dialogue_node_speaker_avatar": "Speaker avatar URL",
500+
"dialogue_node_speaker_side": "Speaker side",
501+
"dialogue_side_left": "Left",
502+
"dialogue_side_right": "Right",
503+
"dialogue_node_content": "Content",
504+
"dialogue_node_next": "Default next node",
505+
"dialogue_node_next_none": "— (end of script)",
506+
"dialogue_node_on_enter_rewards": "On-enter rewards",
507+
"dialogue_options_title": "Options",
508+
"dialogue_add_option": "Add option",
509+
"dialogue_remove_option": "Remove option",
510+
"dialogue_option_id": "Option ID",
511+
"dialogue_option_label": "Label",
512+
"dialogue_option_next": "Next node",
513+
"dialogue_option_rewards": "Option rewards",
514+
"dialogue_option_action": "Click action",
515+
"dialogue_delete_script_title": "Delete this script?",
516+
"dialogue_delete_script_desc": "This permanently removes the script and all player progress for it.",
517+
"dialogue_back_to_scripts": "Back to scripts",
518+
"dialogue_save_script": "Save script",
519+
"dialogue_col_name": "Name",
520+
"dialogue_col_alias": "Alias",
521+
"dialogue_col_nodes": "Nodes",
522+
"dialogue_col_repeatable": "Repeatable",
523+
"dialogue_col_status": "Status",
524+
"dialogue_draft_badge": "Draft (no alias)",
525+
"dialogue_error_name_required": "Name is required",
526+
"dialogue_error_start_node_invalid": "Start node must match one of the nodes",
527+
"dialogue_error_duplicate_node_id": "Duplicate node id",
528+
"dialogue_error_duplicate_option_id": "Duplicate option id within a node",
529+
"dialogue_error_bad_next": "'next' points to a node that does not exist",
530+
531+
"link_none": "No action",
532+
"link_external": "External URL",
533+
"link_internal": "Internal route",
534+
"link_url": "URL",
535+
"link_open_in": "Open in",
536+
"link_open_blank": "New tab",
537+
"link_open_self": "Same tab",
538+
"link_route": "Route",
539+
"link_param_label": "Param",
540+
"link_add_param": "Add param",
541+
"link_pending_badge": "Pending module",
542+
"link_action": "Click action",
543+
"link_param_hint_uuid": "Must be a UUID"
410544
}

apps/admin/messages/zh.json

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,5 +406,139 @@
406406
"shop_growth_only": "仅成长礼包可用。",
407407
"shop_back_to_products": "返回商品列表",
408408
"shop_back_to_categories": "返回分类列表",
409-
"shop_manage_stages": "管理成长阶段"
409+
"shop_manage_stages": "管理成长阶段",
410+
411+
"nav_banner": "轮播图",
412+
"nav_dialogue": "对话",
413+
414+
"banner_title": "轮播位",
415+
"banner_new_group": "新建轮播位",
416+
"banner_edit_group": "编辑轮播位",
417+
"banner_empty": "暂无轮播位。",
418+
"banner_failed_load": "加载轮播位失败:",
419+
"banner_group_created": "轮播位已创建",
420+
"banner_group_updated": "轮播位已更新",
421+
"banner_group_deleted": "轮播位已删除",
422+
"banner_failed_create_group": "创建轮播位失败",
423+
"banner_failed_update_group": "更新轮播位失败",
424+
"banner_failed_delete_group": "删除轮播位失败",
425+
"banner_field_alias": "别名",
426+
"banner_field_alias_hint": "客户端按该别名取轮播位。留空则视为未发布草稿。",
427+
"banner_field_name": "名称",
428+
"banner_field_description": "说明",
429+
"banner_field_layout": "布局",
430+
"banner_field_interval": "自动轮播间隔(毫秒)",
431+
"banner_field_active": "启用",
432+
"banner_layout_carousel": "轮播",
433+
"banner_layout_single": "单张",
434+
"banner_layout_grid": "网格",
435+
"banner_banners_title": "轮播图",
436+
"banner_banners_empty": "该轮播位下还没有轮播图。",
437+
"banner_new_banner": "新建轮播图",
438+
"banner_edit_banner": "编辑轮播图",
439+
"banner_banner_created": "轮播图已创建",
440+
"banner_banner_updated": "轮播图已更新",
441+
"banner_banner_deleted": "轮播图已删除",
442+
"banner_failed_create_banner": "创建轮播图失败",
443+
"banner_failed_update_banner": "更新轮播图失败",
444+
"banner_failed_delete_banner": "删除轮播图失败",
445+
"banner_field_banner_title": "标题",
446+
"banner_field_image_mobile": "移动端图片 URL",
447+
"banner_field_image_desktop": "桌面端图片 URL",
448+
"banner_field_alt_text": "替代文本",
449+
"banner_field_sort_order": "排序",
450+
"banner_field_visible_from": "上架时间",
451+
"banner_field_visible_until": "下架时间",
452+
"banner_field_target_type": "投放对象",
453+
"banner_field_target_user_ids": "终端用户 ID(以逗号或换行分隔)",
454+
"banner_target_broadcast": "全员广播",
455+
"banner_target_multicast": "定向多播",
456+
"banner_back_to_groups": "返回轮播位列表",
457+
"banner_back_to_group": "返回轮播位",
458+
"banner_delete_group_title": "删除该轮播位?",
459+
"banner_delete_group_desc": "将同时删除轮播位下所有轮播图。",
460+
"banner_delete_banner_title": "删除该轮播图?",
461+
"banner_delete_banner_desc": "此操作不可撤销。",
462+
"banner_move_up": "上移",
463+
"banner_move_down": "下移",
464+
"banner_draft_badge": "草稿(未设别名)",
465+
"banner_status_active": "启用",
466+
"banner_status_inactive": "停用",
467+
"banner_col_preview": "预览",
468+
"banner_col_title": "标题",
469+
"banner_col_visibility": "可见时段",
470+
"banner_col_target": "投放对象",
471+
"banner_col_link": "跳转",
472+
"banner_col_status": "状态",
473+
"banner_col_actions": "操作",
474+
475+
"dialogue_title": "对话剧本",
476+
"dialogue_new_script": "新建剧本",
477+
"dialogue_edit_script": "编辑剧本",
478+
"dialogue_empty": "暂无对话剧本。",
479+
"dialogue_failed_load": "加载对话剧本失败:",
480+
"dialogue_script_created": "剧本已创建",
481+
"dialogue_script_updated": "剧本已更新",
482+
"dialogue_script_deleted": "剧本已删除",
483+
"dialogue_failed_create_script": "创建剧本失败",
484+
"dialogue_failed_update_script": "更新剧本失败",
485+
"dialogue_failed_delete_script": "删除剧本失败",
486+
"dialogue_field_alias": "别名",
487+
"dialogue_field_alias_hint": "客户端按该别名触发本剧本。留空视为未发布草稿。",
488+
"dialogue_field_name": "名称",
489+
"dialogue_field_description": "说明",
490+
"dialogue_field_start_node": "起始节点",
491+
"dialogue_field_repeatable": "可重复",
492+
"dialogue_field_active": "启用",
493+
"dialogue_nodes_title": "节点",
494+
"dialogue_nodes_empty": "本剧本还没有节点,请先添加一个。",
495+
"dialogue_add_node": "添加节点",
496+
"dialogue_remove_node": "删除节点",
497+
"dialogue_node_id": "节点 ID",
498+
"dialogue_node_speaker_name": "说话人名称",
499+
"dialogue_node_speaker_avatar": "说话人头像 URL",
500+
"dialogue_node_speaker_side": "说话人位置",
501+
"dialogue_side_left": "",
502+
"dialogue_side_right": "",
503+
"dialogue_node_content": "台词",
504+
"dialogue_node_next": "默认下一节点",
505+
"dialogue_node_next_none": "—(剧本结束)",
506+
"dialogue_node_on_enter_rewards": "进入节点奖励",
507+
"dialogue_options_title": "选项",
508+
"dialogue_add_option": "添加选项",
509+
"dialogue_remove_option": "删除选项",
510+
"dialogue_option_id": "选项 ID",
511+
"dialogue_option_label": "文案",
512+
"dialogue_option_next": "下一节点",
513+
"dialogue_option_rewards": "选项奖励",
514+
"dialogue_option_action": "跳转动作",
515+
"dialogue_delete_script_title": "删除该剧本?",
516+
"dialogue_delete_script_desc": "将删除剧本及所有玩家进度,不可恢复。",
517+
"dialogue_back_to_scripts": "返回剧本列表",
518+
"dialogue_save_script": "保存剧本",
519+
"dialogue_col_name": "名称",
520+
"dialogue_col_alias": "别名",
521+
"dialogue_col_nodes": "节点数",
522+
"dialogue_col_repeatable": "可重复",
523+
"dialogue_col_status": "状态",
524+
"dialogue_draft_badge": "草稿(未设别名)",
525+
"dialogue_error_name_required": "名称必填",
526+
"dialogue_error_start_node_invalid": "起始节点必须匹配其中一个节点",
527+
"dialogue_error_duplicate_node_id": "节点 ID 重复",
528+
"dialogue_error_duplicate_option_id": "同一节点内的选项 ID 重复",
529+
"dialogue_error_bad_next": "'next' 指向的节点不存在",
530+
531+
"link_none": "不跳转",
532+
"link_external": "外部链接",
533+
"link_internal": "内部路由",
534+
"link_url": "URL",
535+
"link_open_in": "打开方式",
536+
"link_open_blank": "新标签页",
537+
"link_open_self": "当前标签页",
538+
"link_route": "路由",
539+
"link_param_label": "参数",
540+
"link_add_param": "添加参数",
541+
"link_pending_badge": "模块待实现",
542+
"link_action": "跳转动作",
543+
"link_param_hint_uuid": "必须是 UUID"
410544
}

apps/admin/src/components/AppSidebar.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { Link } from "@tanstack/react-router"
33
import {
44
ArrowLeftRight,
55
CalendarCheck,
6+
GalleryHorizontal,
67
KeyRound,
78
LayoutDashboard,
89
Mail,
10+
MessagesSquare,
911
Package,
1012
ShoppingCart,
1113
} from "lucide-react"
@@ -34,6 +36,8 @@ function getNavItems() {
3436
{ title: m.nav_exchange(), to: "/exchange" as const, icon: ArrowLeftRight },
3537
{ title: m.nav_shop(), to: "/shop" as const, icon: ShoppingCart },
3638
{ title: m.nav_mail(), to: "/mail" as const, icon: Mail },
39+
{ title: m.nav_banner(), to: "/banner" as const, icon: GalleryHorizontal },
40+
{ title: m.nav_dialogue(), to: "/dialogue" as const, icon: MessagesSquare },
3741
{ title: m.nav_api_keys(), to: "/api-keys" as const, icon: KeyRound },
3842
]
3943
}

0 commit comments

Comments
 (0)